mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-29 04:23:19 +02:00
Merge branch 'main' into rjm/3602-router-logs
This commit is contained in:
commit
85556c6e21
11 changed files with 790 additions and 146 deletions
76
.github/ISSUE_TEMPLATE/design-issue.yml
vendored
Normal file
76
.github/ISSUE_TEMPLATE/design-issue.yml
vendored
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
name: Design Issue / story
|
||||||
|
description: Specifically for design and content tickets
|
||||||
|
labels: design, content
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
id: title-help
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
> Titles should be short, descriptive, and compelling. Use sentence case: don't capitalize words unnecessarily.
|
||||||
|
- type: textarea
|
||||||
|
id: issue-description
|
||||||
|
attributes:
|
||||||
|
label: Issue description
|
||||||
|
description: |
|
||||||
|
Describe the issue so that someone who wasn't present for its discovery can understand why it matters. For stories, use the user story format (e.g., As a user, I want, so that). Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax).
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
id: acceptance-criteria-design
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### Acceptance criteria for design updates
|
||||||
|
- [ ] Used components from the Figma design system wherein possible
|
||||||
|
- [ ] Merged draft design(s) into the official [registrar](https://www.figma.com/design/xFtGLHVrhp0lvwh0gYWnbx/.gov-registar?m=auto) or [get.gov](https://www.figma.com/design/qeWM03sfjXgHBHB23rsD6X/get.gov?m=auto&t=NevyFHpikXLWEwaL-6) Figma pages
|
||||||
|
- [ ] (If applicable) Updated components from the [Figma design system](https://www.figma.com/design/G2HANRHy8pnlY5ENvmpKY8/.gov-Design-System?m=auto) if there's any inconsistencies with production
|
||||||
|
<br>
|
||||||
|
- type: markdown
|
||||||
|
id: acceptance-criteria-content
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### Acceptance criteria for content updates
|
||||||
|
- [ ] **Followed the [content guide](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?usp=sharing) instructions, including:**
|
||||||
|
- [ ] Use official terms in the [word list](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.2et92p0) wherein possible, and avoiding synonyms or alternative terms
|
||||||
|
- [ ] Use [content blocks](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.23ckvvd) whenever possible (consider creating new content blocks for new content that will be referenced heavily)
|
||||||
|
- [ ] Check for readibility using the [Hemingway Editor](https://hemingwayapp.com/)
|
||||||
|
- [ ] Any external links have an [external link icon](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.4i7ojhp) and open in a new tab
|
||||||
|
|
||||||
|
- [ ] **Instructions specific to get.gov ([instructions for how to update the public site](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.1pxezwc)):**
|
||||||
|
- [ ] Update content in the [get.gov design file](https://www.figma.com/design/qeWM03sfjXgHBHB23rsD6X/get.gov?m=auto&t=NevyFHpikXLWEwaL-6) in Figma
|
||||||
|
- [ ] Update the [relevant pages](https://drive.google.com/drive/u/1/folders/1kPsaM6wTli5yjTx1k1QZNlSzYZ-usW4x) in Google Drive's Content folder
|
||||||
|
- [ ] Links [open in a new window](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.g0jcbi63s6zm) if the user will need to reference text on the public site while viewing the link
|
||||||
|
|
||||||
|
- [ ] **Instructions specific to the manage.get.gov ([instructions for how to update the registrar](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.1hmsyys)):**
|
||||||
|
- [ ] Update content in the [registrar design file](https://www.figma.com/design/xFtGLHVrhp0lvwh0gYWnbx/.gov-registar?m=auto) in Figma
|
||||||
|
- [ ] Update the [relevant pages](https://drive.google.com/drive/folders/1dlv_w9zT-W_TStG-7icag6lqcQi86WUs?usp=drive_link) in Google Drive's Content folder
|
||||||
|
- [ ] Links [open in a new window](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.g0jcbi63s6zm)
|
||||||
|
|
||||||
|
- [ ] **Instructions specific to emails:**
|
||||||
|
- [ ] TBD
|
||||||
|
<br>
|
||||||
|
- type: textarea
|
||||||
|
id: additional-acceptance-criteria
|
||||||
|
attributes:
|
||||||
|
label: Additional acceptance criteria
|
||||||
|
description: "If known, add more statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate."
|
||||||
|
placeholder: "- [ ]"
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: "Share any other thoughts, like how this might be implemented or fixed. Screenshots and links to documents/discussions are welcome."
|
||||||
|
- type: textarea
|
||||||
|
id: links-to-other-issues
|
||||||
|
attributes:
|
||||||
|
label: Links to other issues
|
||||||
|
description: |
|
||||||
|
"Use a dash (`-`) to start the line. Add an issue by typing "`#`" then the issue number. Add information to describe any dependancies, blockers, etc. (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to). If this is a parent issue, use sub-issues instead of linking other issues here."
|
||||||
|
placeholder: "- 🔄 Relates to..."
|
||||||
|
- type: markdown
|
||||||
|
id: note
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
> We may edit the text in this issue to document our understanding and clarify the product work.
|
||||||
|
|
||||||
|
|
2
.github/ISSUE_TEMPLATE/issue-default.yml
vendored
2
.github/ISSUE_TEMPLATE/issue-default.yml
vendored
|
@ -19,7 +19,7 @@ body:
|
||||||
id: acceptance-criteria
|
id: acceptance-criteria
|
||||||
attributes:
|
attributes:
|
||||||
label: Acceptance criteria
|
label: Acceptance criteria
|
||||||
description: "If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate."
|
description: "If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate. Designers: [Additional considerations](https://github.com/cisagov/manage.get.gov/wiki/Design-work-considerations) are listed in the wiki and can be adapted to ACs here."
|
||||||
placeholder: "- [ ]"
|
placeholder: "- [ ]"
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: additional-context
|
id: additional-context
|
||||||
|
|
|
@ -5,7 +5,7 @@ A domain manager was removed from {{ domain.name }}.
|
||||||
|
|
||||||
REMOVED BY: {{ removed_by.email }}
|
REMOVED BY: {{ removed_by.email }}
|
||||||
REMOVED ON: {{ date }}
|
REMOVED ON: {{ date }}
|
||||||
MANAGER REMOVED: {{ manager_removed.email }}
|
MANAGER REMOVED: {{ manager_removed_email }}
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
An update was made to your .gov organization.
|
||||||
|
|
||||||
|
ORGANIZATION: {{ portfolio }}
|
||||||
|
UPDATED BY: {{ editor.email }}
|
||||||
|
UPDATED ON: {{ date }}
|
||||||
|
INFORMATION UPDATED: {{ updated_info }}
|
||||||
|
|
||||||
|
You can view this update in the .gov registrar <https://manage.get.gov>.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
WHY DID YOU RECEIVE THIS EMAIL?
|
||||||
|
You're listed as an admin for {{ portfolio }}, so you'll receive a
|
||||||
|
notification whenever changes are made to that .gov organization.
|
||||||
|
|
||||||
|
If you have questions or concerns, reach out to the person who made the change or reply
|
||||||
|
to this email.
|
||||||
|
|
||||||
|
THANK YOU
|
||||||
|
.Gov helps the public identify official, trusted information. Thank you for using a .gov
|
||||||
|
domain.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
|
||||||
|
(CISA) <https://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
An update was made to your .gov organization
|
|
@ -1,5 +1,5 @@
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock, ANY
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from registrar.models.domain import Domain
|
from registrar.models.domain import Domain
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
|
@ -13,13 +13,15 @@ from registrar.utility.email_invitations import (
|
||||||
_send_portfolio_admin_addition_emails_to_portfolio_admins,
|
_send_portfolio_admin_addition_emails_to_portfolio_admins,
|
||||||
_send_portfolio_admin_removal_emails_to_portfolio_admins,
|
_send_portfolio_admin_removal_emails_to_portfolio_admins,
|
||||||
send_domain_invitation_email,
|
send_domain_invitation_email,
|
||||||
send_emails_to_domain_managers,
|
_send_domain_invitation_update_emails_to_domain_managers,
|
||||||
|
send_domain_manager_removal_emails_to_domain_managers,
|
||||||
send_portfolio_admin_addition_emails,
|
send_portfolio_admin_addition_emails,
|
||||||
send_portfolio_admin_removal_emails,
|
send_portfolio_admin_removal_emails,
|
||||||
send_portfolio_invitation_email,
|
send_portfolio_invitation_email,
|
||||||
send_portfolio_invitation_remove_email,
|
send_portfolio_invitation_remove_email,
|
||||||
send_portfolio_member_permission_remove_email,
|
send_portfolio_member_permission_remove_email,
|
||||||
send_portfolio_member_permission_update_email,
|
send_portfolio_member_permission_update_email,
|
||||||
|
send_portfolio_update_emails_to_portfolio_admins,
|
||||||
)
|
)
|
||||||
|
|
||||||
from api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
|
@ -33,7 +35,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
@patch("registrar.utility.email_invitations._send_domain_invitation_email")
|
||||||
@patch("registrar.utility.email_invitations._normalize_domains")
|
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||||
def test_send_domain_invitation_email(
|
def test_send_domain_invitation_email(
|
||||||
self,
|
self,
|
||||||
|
@ -98,7 +100,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
@patch("registrar.utility.email_invitations._send_domain_invitation_email")
|
||||||
@patch("registrar.utility.email_invitations._normalize_domains")
|
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||||
def test_send_domain_invitation_email_multiple_domains(
|
def test_send_domain_invitation_email_multiple_domains(
|
||||||
self,
|
self,
|
||||||
|
@ -234,7 +236,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
@patch("registrar.utility.email_invitations._send_domain_invitation_email")
|
||||||
@patch("registrar.utility.email_invitations._normalize_domains")
|
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||||
def test_send_domain_invitation_email_raises_sending_email_exception(
|
def test_send_domain_invitation_email_raises_sending_email_exception(
|
||||||
self,
|
self,
|
||||||
|
@ -281,10 +283,10 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
self.assertEqual(str(context.exception), "Error sending email")
|
self.assertEqual(str(context.exception), "Error sending email")
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@patch("registrar.utility.email_invitations.send_emails_to_domain_managers")
|
@patch("registrar.utility.email_invitations._send_domain_invitation_update_emails_to_domain_managers")
|
||||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
@patch("registrar.utility.email_invitations._send_domain_invitation_email")
|
||||||
@patch("registrar.utility.email_invitations._normalize_domains")
|
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||||
def test_send_domain_invitation_email_manager_emails_send_mail_exception(
|
def test_send_domain_invitation_email_manager_emails_send_mail_exception(
|
||||||
self,
|
self,
|
||||||
|
@ -295,7 +297,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
mock_send_domain_manager_emails,
|
mock_send_domain_manager_emails,
|
||||||
):
|
):
|
||||||
"""Test sending domain invitation email for one domain and assert exception
|
"""Test sending domain invitation email for one domain and assert exception
|
||||||
when send_emails_to_domain_managers fails.
|
when _send_domain_invitation_update_emails_to_domain_managers fails.
|
||||||
"""
|
"""
|
||||||
# Setup
|
# Setup
|
||||||
mock_domain = MagicMock(name="domain1")
|
mock_domain = MagicMock(name="domain1")
|
||||||
|
@ -354,7 +356,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
mock_send_templated_email.return_value = None # No exception means success
|
mock_send_templated_email.return_value = None # No exception means success
|
||||||
|
|
||||||
# Call function
|
# Call function
|
||||||
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||||
|
|
||||||
# Assertions
|
# Assertions
|
||||||
self.assertTrue(result) # All emails should be successfully sent
|
self.assertTrue(result) # All emails should be successfully sent
|
||||||
|
@ -394,7 +396,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
mock_send_templated_email.side_effect = EmailSendingError("Email sending failed")
|
mock_send_templated_email.side_effect = EmailSendingError("Email sending failed")
|
||||||
|
|
||||||
# Call function
|
# Call function
|
||||||
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||||
|
|
||||||
# Assertions
|
# Assertions
|
||||||
self.assertFalse(result) # The result should be False as email sending failed
|
self.assertFalse(result) # The result should be False as email sending failed
|
||||||
|
@ -426,7 +428,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
mock_filter.return_value = []
|
mock_filter.return_value = []
|
||||||
|
|
||||||
# Call function
|
# Call function
|
||||||
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||||
|
|
||||||
# Assertions
|
# Assertions
|
||||||
self.assertTrue(result) # No emails to send, so it should return True
|
self.assertTrue(result) # No emails to send, so it should return True
|
||||||
|
@ -457,7 +459,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
mock_send_templated_email.side_effect = [None, EmailSendingError("Failed to send email")]
|
mock_send_templated_email.side_effect = [None, EmailSendingError("Failed to send email")]
|
||||||
|
|
||||||
# Call function
|
# Call function
|
||||||
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||||
|
|
||||||
# Assertions
|
# Assertions
|
||||||
self.assertFalse(result) # One email failed, so result should be False
|
self.assertFalse(result) # One email failed, so result should be False
|
||||||
|
@ -1112,3 +1114,208 @@ class TestSendPortfolioInvitationRemoveEmail(unittest.TestCase):
|
||||||
|
|
||||||
# Assertions
|
# Assertions
|
||||||
mock_logger.warning.assert_not_called() # Function should fail before logging email failure
|
mock_logger.warning.assert_not_called() # Function should fail before logging email failure
|
||||||
|
|
||||||
|
|
||||||
|
class SendDomainManagerRemovalEmailsToManagersTests(unittest.TestCase):
|
||||||
|
"""Unit tests for send_domain_manager_removal_emails_to_domain_managers function."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data."""
|
||||||
|
self.email = "removed.admin@example.com"
|
||||||
|
self.requestor_email = "requestor@example.com"
|
||||||
|
self.domain = MagicMock(spec=Domain)
|
||||||
|
self.domain.name = "Test Domain"
|
||||||
|
|
||||||
|
# Mock domain manager users
|
||||||
|
self.manager_user1 = MagicMock(spec=User)
|
||||||
|
self.manager_user1.email = "manager1@example.com"
|
||||||
|
|
||||||
|
self.manager_user2 = MagicMock(spec=User)
|
||||||
|
self.manager_user2.email = "manager2@example.com"
|
||||||
|
|
||||||
|
self.domain_manager1 = MagicMock(spec=UserDomainRole)
|
||||||
|
self.domain_manager1.user = self.manager_user1
|
||||||
|
|
||||||
|
self.domain_manager2 = MagicMock(spec=UserDomainRole)
|
||||||
|
self.domain_manager2.user = self.manager_user2
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||||
|
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||||
|
def test_send_email_success(self, mock_filter, mock_send_templated_email):
|
||||||
|
"""Test successful sending of domain manager removal emails."""
|
||||||
|
mock_filter.return_value.exclude.return_value = [self.domain_manager1]
|
||||||
|
mock_send_templated_email.return_value = None # No exception means success
|
||||||
|
|
||||||
|
result = send_domain_manager_removal_emails_to_domain_managers(
|
||||||
|
removed_by_user=self.manager_user1,
|
||||||
|
manager_removed=self.manager_user2,
|
||||||
|
manager_removed_email=self.manager_user2.email,
|
||||||
|
domain=self.domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_filter.assert_called_once_with(domain=self.domain)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
|
to_address=self.manager_user1.email,
|
||||||
|
context={
|
||||||
|
"domain": self.domain,
|
||||||
|
"removed_by": self.manager_user1,
|
||||||
|
"manager_removed_email": self.manager_user2.email,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||||
|
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||||
|
def test_send_email_success_when_no_user(self, mock_filter, mock_send_templated_email):
|
||||||
|
"""Test successful sending of domain manager removal emails."""
|
||||||
|
mock_filter.return_value = [self.domain_manager1, self.domain_manager2]
|
||||||
|
mock_send_templated_email.return_value = None # No exception means success
|
||||||
|
|
||||||
|
result = send_domain_manager_removal_emails_to_domain_managers(
|
||||||
|
removed_by_user=self.manager_user1,
|
||||||
|
manager_removed=None,
|
||||||
|
manager_removed_email=self.manager_user2.email,
|
||||||
|
domain=self.domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_filter.assert_called_once_with(domain=self.domain)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
|
to_address=self.manager_user1.email,
|
||||||
|
context={
|
||||||
|
"domain": self.domain,
|
||||||
|
"removed_by": self.manager_user1,
|
||||||
|
"manager_removed_email": self.manager_user2.email,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
|
to_address=self.manager_user2.email,
|
||||||
|
context={
|
||||||
|
"domain": self.domain,
|
||||||
|
"removed_by": self.manager_user1,
|
||||||
|
"manager_removed_email": self.manager_user2.email,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError)
|
||||||
|
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||||
|
def test_send_email_failure(self, mock_filter, mock_send_templated_email):
|
||||||
|
"""Test handling of failure in sending admin removal emails."""
|
||||||
|
mock_filter.return_value.exclude.return_value = [self.domain_manager1, self.domain_manager2]
|
||||||
|
|
||||||
|
result = send_domain_manager_removal_emails_to_domain_managers(
|
||||||
|
removed_by_user=self.manager_user1,
|
||||||
|
manager_removed=self.manager_user2,
|
||||||
|
manager_removed_email=self.manager_user2.email,
|
||||||
|
domain=self.domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
mock_filter.assert_called_once_with(domain=self.domain)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
|
to_address=self.manager_user1.email,
|
||||||
|
context={
|
||||||
|
"domain": self.domain,
|
||||||
|
"removed_by": self.manager_user1,
|
||||||
|
"manager_removed_email": self.manager_user2.email,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
|
to_address=self.manager_user2.email,
|
||||||
|
context={
|
||||||
|
"domain": self.domain,
|
||||||
|
"removed_by": self.manager_user1,
|
||||||
|
"manager_removed_email": self.manager_user2.email,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||||
|
def test_no_managers_to_notify(self, mock_filter):
|
||||||
|
"""Test case where there are no domain managers to notify."""
|
||||||
|
mock_filter.return_value.exclude.return_value = [] # No managers
|
||||||
|
|
||||||
|
result = send_domain_manager_removal_emails_to_domain_managers(
|
||||||
|
removed_by_user=self.manager_user1,
|
||||||
|
manager_removed=self.manager_user2,
|
||||||
|
manager_removed_email=self.manager_user2.email,
|
||||||
|
domain=self.domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result) # No emails sent, but also no failures
|
||||||
|
mock_filter.assert_called_once_with(domain=self.domain)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendPortfolioOrganizationUpdateEmail(unittest.TestCase):
|
||||||
|
"""Unit tests for send_portfolio_update_emails_to_portfolio_admins function."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data."""
|
||||||
|
self.email = "removed.admin@example.com"
|
||||||
|
self.requestor_email = "requestor@example.com"
|
||||||
|
self.portfolio = MagicMock(spec=Portfolio, name="Portfolio")
|
||||||
|
self.portfolio.organization_name = "Test Organization"
|
||||||
|
|
||||||
|
# Mock portfolio admin users
|
||||||
|
self.admin_user1 = MagicMock(spec=User)
|
||||||
|
self.admin_user1.email = "admin1@example.com"
|
||||||
|
|
||||||
|
self.admin_user2 = MagicMock(spec=User)
|
||||||
|
self.admin_user2.email = "admin2@example.com"
|
||||||
|
|
||||||
|
self.portfolio_admin1 = MagicMock(spec=UserPortfolioPermission)
|
||||||
|
self.portfolio_admin1.user = self.admin_user1
|
||||||
|
self.portfolio_admin1.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
|
||||||
|
self.portfolio_admin2 = MagicMock(spec=UserPortfolioPermission)
|
||||||
|
self.portfolio_admin2.user = self.admin_user2
|
||||||
|
self.portfolio_admin2.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||||
|
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
|
||||||
|
def test_send_portfolio_update_emails_to_portfolio_admins(self, mock_filter, mock_send_templated_email):
|
||||||
|
"""Test send_portfolio_update_emails_to_portfolio_admins sends templated email."""
|
||||||
|
# Mock data
|
||||||
|
editor = self.admin_user1
|
||||||
|
updated_page = "Organization"
|
||||||
|
|
||||||
|
mock_filter.return_value = [self.portfolio_admin1, self.portfolio_admin2]
|
||||||
|
mock_send_templated_email.return_value = None # No exception means success
|
||||||
|
|
||||||
|
# Call function
|
||||||
|
result = send_portfolio_update_emails_to_portfolio_admins(editor, self.portfolio, updated_page)
|
||||||
|
|
||||||
|
mock_filter.assert_called_once_with(
|
||||||
|
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_org_update_notification.txt",
|
||||||
|
"emails/portfolio_org_update_notification_subject.txt",
|
||||||
|
to_address=self.admin_user1.email,
|
||||||
|
context=ANY,
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_org_update_notification.txt",
|
||||||
|
"emails/portfolio_org_update_notification_subject.txt",
|
||||||
|
to_address=self.admin_user2.email,
|
||||||
|
context=ANY,
|
||||||
|
)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
|
@ -1084,8 +1084,8 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@patch("registrar.views.domain.send_templated_email")
|
@patch("registrar.views.domain.send_domain_manager_removal_emails_to_domain_managers")
|
||||||
def test_domain_remove_manager(self, mock_send_templated_email):
|
def test_domain_remove_manager(self, mock_send_email):
|
||||||
"""Removing a domain manager sends notification email to other domain managers."""
|
"""Removing a domain manager sends notification email to other domain managers."""
|
||||||
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
|
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
|
||||||
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
|
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
|
||||||
|
@ -1094,11 +1094,11 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify that the notification emails were sent to domain manager
|
# Verify that the notification emails were sent to domain manager
|
||||||
mock_send_templated_email.assert_called_once_with(
|
mock_send_email.assert_called_once_with(
|
||||||
"emails/domain_manager_deleted_notification.txt",
|
removed_by_user=self.user,
|
||||||
"emails/domain_manager_deleted_notification_subject.txt",
|
manager_removed=self.manager,
|
||||||
to_address="info@example.com",
|
manager_removed_email=self.manager.email,
|
||||||
context=ANY,
|
domain=self.domain,
|
||||||
)
|
)
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
|
|
@ -376,8 +376,8 @@ class TestPortfolio(WebTest):
|
||||||
self.assertContains(page, "Non-Federal Agency")
|
self.assertContains(page, "Non-Federal Agency")
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_domain_org_name_address_form(self):
|
def test_org_form_invalid_update(self):
|
||||||
"""Submitting changes works on the org name address page."""
|
"""Organization form will not redirect on invalid formsets."""
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
portfolio_additional_permissions = [
|
portfolio_additional_permissions = [
|
||||||
|
@ -398,11 +398,79 @@ class TestPortfolio(WebTest):
|
||||||
|
|
||||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
success_result_page = portfolio_org_name_page.form.submit()
|
success_result_page = portfolio_org_name_page.form.submit()
|
||||||
|
# Form will not validate with missing required field (zipcode)
|
||||||
self.assertEqual(success_result_page.status_code, 200)
|
self.assertEqual(success_result_page.status_code, 200)
|
||||||
|
|
||||||
self.assertContains(success_result_page, "6 Downing st")
|
self.assertContains(success_result_page, "6 Downing st")
|
||||||
self.assertContains(success_result_page, "London")
|
self.assertContains(success_result_page, "London")
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_org_form_valid_update(self):
|
||||||
|
"""Organization form will redirect on valid formsets."""
|
||||||
|
with override_flag("organization_feature", active=True):
|
||||||
|
self.app.set_user(self.user.username)
|
||||||
|
portfolio_additional_permissions = [
|
||||||
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||||
|
]
|
||||||
|
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
self.portfolio.address_line1 = "1600 Penn Ave"
|
||||||
|
self.portfolio.save()
|
||||||
|
portfolio_org_name_page = self.app.get(reverse("organization"))
|
||||||
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
|
|
||||||
|
# Form validates and redirects with all required fields
|
||||||
|
portfolio_org_name_page.form["address_line1"] = "6 Downing st"
|
||||||
|
portfolio_org_name_page.form["city"] = "London"
|
||||||
|
portfolio_org_name_page.form["zipcode"] = "11111"
|
||||||
|
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
success_result_page = portfolio_org_name_page.form.submit()
|
||||||
|
self.assertEqual(success_result_page.status_code, 302)
|
||||||
|
|
||||||
|
@boto3_mocking.patching
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.views.portfolios.send_portfolio_update_emails_to_portfolio_admins")
|
||||||
|
def test_org_update_sends_admin_email(self, mock_send_organization_update_email):
|
||||||
|
"""Updating organization information emails organization admin."""
|
||||||
|
with override_flag("organization_feature", active=True):
|
||||||
|
self.app.set_user(self.user.username)
|
||||||
|
self.admin, _ = User.objects.get_or_create(
|
||||||
|
email="mayor@igorville.com", first_name="Hello", last_name="World"
|
||||||
|
)
|
||||||
|
|
||||||
|
portfolio_additional_permissions = [
|
||||||
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||||
|
]
|
||||||
|
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions
|
||||||
|
)
|
||||||
|
portfolio_permission_admin, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.admin,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
additional_permissions=portfolio_additional_permissions,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.portfolio.address_line1 = "1600 Penn Ave"
|
||||||
|
self.portfolio.save()
|
||||||
|
portfolio_org_name_page = self.app.get(reverse("organization"))
|
||||||
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
|
portfolio_org_name_page.form["address_line1"] = "6 Downing st"
|
||||||
|
portfolio_org_name_page.form["city"] = "London"
|
||||||
|
portfolio_org_name_page.form["zipcode"] = "11111"
|
||||||
|
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
success_result_page = portfolio_org_name_page.form.submit()
|
||||||
|
self.assertEqual(success_result_page.status_code, 302)
|
||||||
|
|
||||||
|
# Verify that the notification emails were sent to domain manager
|
||||||
|
mock_send_organization_update_email.assert_called_once()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_portfolio_in_session_when_organization_feature_active(self):
|
def test_portfolio_in_session_when_organization_feature_active(self):
|
||||||
"""When organization_feature flag is true and user has a portfolio,
|
"""When organization_feature flag is true and user has a portfolio,
|
||||||
|
@ -1657,16 +1725,19 @@ class TestPortfolioMemberDeleteView(WebTest):
|
||||||
self.user = create_test_user()
|
self.user = create_test_user()
|
||||||
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
|
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
|
||||||
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||||
|
self.domain_information, _ = DomainInformation.objects.get_or_create(
|
||||||
|
creator=self.user, domain=self.domain, portfolio=self.portfolio
|
||||||
|
)
|
||||||
self.role, _ = UserDomainRole.objects.get_or_create(
|
self.role, _ = UserDomainRole.objects.get_or_create(
|
||||||
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
|
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
|
||||||
)
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
UserPortfolioPermission.objects.all().delete()
|
UserPortfolioPermission.objects.all().delete()
|
||||||
|
DomainInformation.objects.all().delete()
|
||||||
Portfolio.objects.all().delete()
|
Portfolio.objects.all().delete()
|
||||||
UserDomainRole.objects.all().delete()
|
UserDomainRole.objects.all().delete()
|
||||||
DomainRequest.objects.all().delete()
|
DomainRequest.objects.all().delete()
|
||||||
DomainInformation.objects.all().delete()
|
|
||||||
Domain.objects.all().delete()
|
Domain.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
@ -1676,7 +1747,10 @@ class TestPortfolioMemberDeleteView(WebTest):
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
|
@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):
|
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||||
|
def test_portfolio_member_delete_view_members_table_active_requests(
|
||||||
|
self, send_domain_manager_removal_emails, send_member_removal, send_removal_emails
|
||||||
|
):
|
||||||
"""Error state w/ deleting a member with active request on Members Table"""
|
"""Error state w/ deleting a member with active request on Members Table"""
|
||||||
# I'm a user
|
# I'm a user
|
||||||
UserPortfolioPermission.objects.get_or_create(
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
@ -1718,13 +1792,18 @@ class TestPortfolioMemberDeleteView(WebTest):
|
||||||
send_removal_emails.assert_not_called()
|
send_removal_emails.assert_not_called()
|
||||||
# assert that send_portfolio_member_permission_remove_email is not called
|
# assert that send_portfolio_member_permission_remove_email is not called
|
||||||
send_member_removal.assert_not_called()
|
send_member_removal.assert_not_called()
|
||||||
|
# assert that send_domain_manager_removal_emails is not called
|
||||||
|
send_domain_manager_removal_emails.assert_not_called()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
|
@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):
|
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||||
|
def test_portfolio_member_delete_view_members_table_only_admin(
|
||||||
|
self, send_domain_manager_removal_emails, send_member_removal, send_removal_emails
|
||||||
|
):
|
||||||
"""Error state w/ deleting a member that's the only admin on Members Table"""
|
"""Error state w/ deleting a member that's the only admin on Members Table"""
|
||||||
|
|
||||||
# I'm a user with admin permission
|
# I'm a user with admin permission
|
||||||
|
@ -1757,13 +1836,18 @@ class TestPortfolioMemberDeleteView(WebTest):
|
||||||
send_removal_emails.assert_not_called()
|
send_removal_emails.assert_not_called()
|
||||||
# assert that send_portfolio_member_permission_remove_email is not called
|
# assert that send_portfolio_member_permission_remove_email is not called
|
||||||
send_member_removal.assert_not_called()
|
send_member_removal.assert_not_called()
|
||||||
|
# assert that send_domain_manager_removal_emails is not called
|
||||||
|
send_domain_manager_removal_emails.assert_not_called()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
|
@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):
|
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||||
|
def test_portfolio_member_table_delete_member_success(
|
||||||
|
self, send_domain_manager_removal_emails, send_member_removal, mock_send_removal_emails
|
||||||
|
):
|
||||||
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
|
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
|
||||||
|
|
||||||
# I'm a user
|
# I'm a user
|
||||||
|
@ -1788,6 +1872,9 @@ class TestPortfolioMemberDeleteView(WebTest):
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set up the member as a domain manager
|
||||||
|
UserDomainRole.objects.get_or_create(user=member, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
|
||||||
# Member removal email sent successfully
|
# Member removal email sent successfully
|
||||||
send_member_removal.return_value = True
|
send_member_removal.return_value = True
|
||||||
|
|
||||||
|
@ -1815,6 +1902,8 @@ class TestPortfolioMemberDeleteView(WebTest):
|
||||||
mock_send_removal_emails.assert_not_called()
|
mock_send_removal_emails.assert_not_called()
|
||||||
# assert that send_portfolio_member_permission_remove_email is called
|
# assert that send_portfolio_member_permission_remove_email is called
|
||||||
send_member_removal.assert_called_once()
|
send_member_removal.assert_called_once()
|
||||||
|
# assert that send_domain_manager_removal_emails_to_domain_managers
|
||||||
|
send_domain_manager_removal_emails.assert_called_once()
|
||||||
|
|
||||||
# Get the arguments passed to send_portfolio_member_permission_remove_email
|
# Get the arguments passed to send_portfolio_member_permission_remove_email
|
||||||
_, called_kwargs = send_member_removal.call_args
|
_, called_kwargs = send_member_removal.call_args
|
||||||
|
@ -1824,6 +1913,15 @@ class TestPortfolioMemberDeleteView(WebTest):
|
||||||
self.assertEqual(called_kwargs["permissions"].user, upp.user)
|
self.assertEqual(called_kwargs["permissions"].user, upp.user)
|
||||||
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
|
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
|
||||||
|
|
||||||
|
# Get the arguments passed to send_domain_manager_removal_emails_to_domain_managers
|
||||||
|
_, called_kwargs = send_domain_manager_removal_emails.call_args
|
||||||
|
|
||||||
|
# Assert the email content
|
||||||
|
self.assertEqual(called_kwargs["removed_by_user"], self.user)
|
||||||
|
self.assertEqual(called_kwargs["manager_removed"], upp.user)
|
||||||
|
self.assertEqual(called_kwargs["manager_removed_email"], upp.user.email)
|
||||||
|
self.assertEqual(called_kwargs["domain"], self.domain)
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
|
@ -2639,7 +2737,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("registrar.views.portfolios.send_domain_invitation_email")
|
@patch("registrar.views.portfolios.send_domain_invitation_email")
|
||||||
def test_post_with_valid_added_domains(self, mock_send_domain_email):
|
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||||
|
def test_post_with_valid_added_domains(self, send_domain_manager_removal_emails, mock_send_domain_email):
|
||||||
"""Test that domains can be successfully added."""
|
"""Test that domains can be successfully added."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
@ -2658,6 +2757,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
||||||
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
|
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
|
||||||
|
|
||||||
expected_domains = [self.domain1, self.domain2, self.domain3]
|
expected_domains = [self.domain1, self.domain2, self.domain3]
|
||||||
|
# assert that send_domain_manager_removal_emails_to_domain_managers is not called
|
||||||
|
send_domain_manager_removal_emails.assert_not_called()
|
||||||
# Verify that the invitation email was sent
|
# Verify that the invitation email was sent
|
||||||
mock_send_domain_email.assert_called_once()
|
mock_send_domain_email.assert_called_once()
|
||||||
call_args = mock_send_domain_email.call_args.kwargs
|
call_args = mock_send_domain_email.call_args.kwargs
|
||||||
|
@ -2670,7 +2771,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("registrar.views.portfolios.send_domain_invitation_email")
|
@patch("registrar.views.portfolios.send_domain_invitation_email")
|
||||||
def test_post_with_valid_removed_domains(self, mock_send_domain_email):
|
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||||
|
def test_post_with_valid_removed_domains(self, send_domain_manager_removal_emails, mock_send_domain_email):
|
||||||
"""Test that domains can be successfully removed."""
|
"""Test that domains can be successfully removed."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
@ -2678,6 +2780,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
||||||
domains = [self.domain1, self.domain2, self.domain3]
|
domains = [self.domain1, self.domain2, self.domain3]
|
||||||
UserDomainRole.objects.bulk_create([UserDomainRole(domain=domain, user=self.user) for domain in domains])
|
UserDomainRole.objects.bulk_create([UserDomainRole(domain=domain, user=self.user) for domain in domains])
|
||||||
|
|
||||||
|
send_domain_manager_removal_emails.return_value = True
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"removed_domains": json.dumps([self.domain1.id, self.domain2.id]),
|
"removed_domains": json.dumps([self.domain1.id, self.domain2.id]),
|
||||||
}
|
}
|
||||||
|
@ -2694,7 +2798,19 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
||||||
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
|
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
|
||||||
# assert that send_domain_invitation_email is not called
|
# assert that send_domain_invitation_email is not called
|
||||||
mock_send_domain_email.assert_not_called()
|
mock_send_domain_email.assert_not_called()
|
||||||
|
# assert that send_domain_manager_removal_emails_to_domain_managers is called twice
|
||||||
|
send_domain_manager_removal_emails.assert_any_call(
|
||||||
|
removed_by_user=self.user,
|
||||||
|
manager_removed=self.portfolio_permission.user,
|
||||||
|
manager_removed_email=self.portfolio_permission.user.email,
|
||||||
|
domain=self.domain1,
|
||||||
|
)
|
||||||
|
send_domain_manager_removal_emails.assert_any_call(
|
||||||
|
removed_by_user=self.user,
|
||||||
|
manager_removed=self.portfolio_permission.user,
|
||||||
|
manager_removed_email=self.portfolio_permission.user.email,
|
||||||
|
domain=self.domain2,
|
||||||
|
)
|
||||||
UserDomainRole.objects.all().delete()
|
UserDomainRole.objects.all().delete()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
|
|
@ -3,6 +3,7 @@ from django.conf import settings
|
||||||
from registrar.models import Domain, DomainInvitation, UserDomainRole
|
from registrar.models import Domain, DomainInvitation, UserDomainRole
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
|
from registrar.models.user import User
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
from registrar.utility.errors import (
|
from registrar.utility.errors import (
|
||||||
|
@ -18,6 +19,88 @@ import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_domains(domains: Domain | list[Domain]) -> list[Domain]:
|
||||||
|
"""Ensures domains is always a list."""
|
||||||
|
return [domains] if isinstance(domains, Domain) else domains
|
||||||
|
|
||||||
|
|
||||||
|
def _get_requestor_email(requestor, domains=None, portfolio=None):
|
||||||
|
"""Get the requestor's email or raise an error if it's missing.
|
||||||
|
|
||||||
|
If the requestor is staff, default email is returned.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MissingEmailError
|
||||||
|
"""
|
||||||
|
if requestor.is_staff:
|
||||||
|
return settings.DEFAULT_FROM_EMAIL
|
||||||
|
|
||||||
|
if not requestor.email or requestor.email.strip() == "":
|
||||||
|
domain_names = None
|
||||||
|
if domains:
|
||||||
|
domain_names = ", ".join([domain.name for domain in domains])
|
||||||
|
raise MissingEmailError(email=requestor.email, domain=domain_names, portfolio=portfolio)
|
||||||
|
|
||||||
|
return requestor.email
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_invitation(email, user, domains, requestor, is_member_of_different_org):
|
||||||
|
"""Validate the invitation conditions."""
|
||||||
|
_check_outside_org_membership(email, requestor, is_member_of_different_org)
|
||||||
|
|
||||||
|
for domain in domains:
|
||||||
|
_validate_existing_invitation(email, user, domain)
|
||||||
|
|
||||||
|
# NOTE: should we also be validating against existing user_domain_roles
|
||||||
|
|
||||||
|
|
||||||
|
def _check_outside_org_membership(email, requestor, is_member_of_different_org):
|
||||||
|
"""Raise an error if the email belongs to a different organization."""
|
||||||
|
if (
|
||||||
|
flag_is_active_for_user(requestor, "organization_feature")
|
||||||
|
and not flag_is_active_for_user(requestor, "multiple_portfolios")
|
||||||
|
and is_member_of_different_org
|
||||||
|
):
|
||||||
|
raise OutsideOrgMemberError(email=email)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_existing_invitation(email, user, domain):
|
||||||
|
"""Check for existing invitations and handle their status."""
|
||||||
|
try:
|
||||||
|
invite = DomainInvitation.objects.get(email=email, domain=domain)
|
||||||
|
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
||||||
|
raise AlreadyDomainManagerError(email)
|
||||||
|
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
|
||||||
|
invite.update_cancellation_status()
|
||||||
|
invite.save()
|
||||||
|
else:
|
||||||
|
raise AlreadyDomainInvitedError(email)
|
||||||
|
except DomainInvitation.DoesNotExist:
|
||||||
|
pass
|
||||||
|
if user:
|
||||||
|
if UserDomainRole.objects.filter(user=user, domain=domain).exists():
|
||||||
|
raise AlreadyDomainManagerError(email)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_domain_invitation_email(email, requestor_email, domains, requested_user):
|
||||||
|
"""Send the invitation email."""
|
||||||
|
try:
|
||||||
|
send_templated_email(
|
||||||
|
"emails/domain_invitation.txt",
|
||||||
|
"emails/domain_invitation_subject.txt",
|
||||||
|
to_address=email,
|
||||||
|
context={
|
||||||
|
"domains": domains,
|
||||||
|
"requestor_email": requestor_email,
|
||||||
|
"invitee_email_address": email,
|
||||||
|
"requested_user": requested_user,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except EmailSendingError as err:
|
||||||
|
domain_names = ", ".join([domain.name for domain in domains])
|
||||||
|
raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err
|
||||||
|
|
||||||
|
|
||||||
def send_domain_invitation_email(
|
def send_domain_invitation_email(
|
||||||
email: str, requestor, domains: Domain | list[Domain], is_member_of_different_org, requested_user=None
|
email: str, requestor, domains: Domain | list[Domain], is_member_of_different_org, requested_user=None
|
||||||
):
|
):
|
||||||
|
@ -46,12 +129,12 @@ def send_domain_invitation_email(
|
||||||
|
|
||||||
_validate_invitation(email, requested_user, domains, requestor, is_member_of_different_org)
|
_validate_invitation(email, requested_user, domains, requestor, is_member_of_different_org)
|
||||||
|
|
||||||
send_invitation_email(email, requestor_email, domains, requested_user)
|
_send_domain_invitation_email(email, requestor_email, domains, requested_user)
|
||||||
|
|
||||||
all_manager_emails_sent = True
|
all_manager_emails_sent = True
|
||||||
# send emails to domain managers
|
# send emails to domain managers
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
if not send_emails_to_domain_managers(
|
if not _send_domain_invitation_update_emails_to_domain_managers(
|
||||||
email=email,
|
email=email,
|
||||||
requestor_email=requestor_email,
|
requestor_email=requestor_email,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
@ -62,7 +145,9 @@ def send_domain_invitation_email(
|
||||||
return all_manager_emails_sent
|
return all_manager_emails_sent
|
||||||
|
|
||||||
|
|
||||||
def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain, requested_user=None):
|
def _send_domain_invitation_update_emails_to_domain_managers(
|
||||||
|
email: str, requestor_email, domain: Domain, requested_user=None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Notifies all domain managers of the provided domain of a change
|
Notifies all domain managers of the provided domain of a change
|
||||||
|
|
||||||
|
@ -96,86 +181,54 @@ def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain,
|
||||||
return all_emails_sent
|
return all_emails_sent
|
||||||
|
|
||||||
|
|
||||||
def _normalize_domains(domains: Domain | list[Domain]) -> list[Domain]:
|
def send_domain_manager_removal_emails_to_domain_managers(
|
||||||
"""Ensures domains is always a list."""
|
removed_by_user: User,
|
||||||
return [domains] if isinstance(domains, Domain) else domains
|
manager_removed: User,
|
||||||
|
manager_removed_email: str,
|
||||||
|
domain: Domain,
|
||||||
def _get_requestor_email(requestor, domains=None, portfolio=None):
|
):
|
||||||
"""Get the requestor's email or raise an error if it's missing.
|
|
||||||
|
|
||||||
If the requestor is staff, default email is returned.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
MissingEmailError
|
|
||||||
"""
|
"""
|
||||||
if requestor.is_staff:
|
Notifies all domain managers that a domain manager has been removed.
|
||||||
return settings.DEFAULT_FROM_EMAIL
|
|
||||||
|
|
||||||
if not requestor.email or requestor.email.strip() == "":
|
Args:
|
||||||
domain_names = None
|
removed_by_user(User): The user who initiated the removal.
|
||||||
if domains:
|
manager_removed(User): The user being removed.
|
||||||
domain_names = ", ".join([domain.name for domain in domains])
|
manager_removed_email(str): The email of the user being removed (in case no User).
|
||||||
raise MissingEmailError(email=requestor.email, domain=domain_names, portfolio=portfolio)
|
domain(Domain): The domain the user is being removed from.
|
||||||
|
|
||||||
return requestor.email
|
Returns:
|
||||||
|
Boolean indicating if all messages were sent successfully.
|
||||||
|
|
||||||
|
"""
|
||||||
def _validate_invitation(email, user, domains, requestor, is_member_of_different_org):
|
all_emails_sent = True
|
||||||
"""Validate the invitation conditions."""
|
# Get each domain manager from list
|
||||||
check_outside_org_membership(email, requestor, is_member_of_different_org)
|
user_domain_roles = UserDomainRole.objects.filter(domain=domain)
|
||||||
|
if manager_removed:
|
||||||
for domain in domains:
|
user_domain_roles = user_domain_roles.exclude(user=manager_removed)
|
||||||
_validate_existing_invitation(email, user, domain)
|
for user_domain_role in user_domain_roles:
|
||||||
|
# Send email to each domain manager
|
||||||
# NOTE: should we also be validating against existing user_domain_roles
|
user = user_domain_role.user
|
||||||
|
try:
|
||||||
|
send_templated_email(
|
||||||
def check_outside_org_membership(email, requestor, is_member_of_different_org):
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
"""Raise an error if the email belongs to a different organization."""
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
if (
|
to_address=user.email,
|
||||||
flag_is_active_for_user(requestor, "organization_feature")
|
context={
|
||||||
and not flag_is_active_for_user(requestor, "multiple_portfolios")
|
"domain": domain,
|
||||||
and is_member_of_different_org
|
"removed_by": removed_by_user,
|
||||||
):
|
"manager_removed_email": manager_removed_email,
|
||||||
raise OutsideOrgMemberError(email=email)
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
def _validate_existing_invitation(email, user, domain):
|
except EmailSendingError:
|
||||||
"""Check for existing invitations and handle their status."""
|
logger.warning(
|
||||||
try:
|
"Could not send notification email to %s for domain %s",
|
||||||
invite = DomainInvitation.objects.get(email=email, domain=domain)
|
user.email,
|
||||||
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
domain.name,
|
||||||
raise AlreadyDomainManagerError(email)
|
exc_info=True,
|
||||||
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
|
)
|
||||||
invite.update_cancellation_status()
|
all_emails_sent = False
|
||||||
invite.save()
|
return all_emails_sent
|
||||||
else:
|
|
||||||
raise AlreadyDomainInvitedError(email)
|
|
||||||
except DomainInvitation.DoesNotExist:
|
|
||||||
pass
|
|
||||||
if user:
|
|
||||||
if UserDomainRole.objects.filter(user=user, domain=domain).exists():
|
|
||||||
raise AlreadyDomainManagerError(email)
|
|
||||||
|
|
||||||
|
|
||||||
def send_invitation_email(email, requestor_email, domains, requested_user):
|
|
||||||
"""Send the invitation email."""
|
|
||||||
try:
|
|
||||||
send_templated_email(
|
|
||||||
"emails/domain_invitation.txt",
|
|
||||||
"emails/domain_invitation_subject.txt",
|
|
||||||
to_address=email,
|
|
||||||
context={
|
|
||||||
"domains": domains,
|
|
||||||
"requestor_email": requestor_email,
|
|
||||||
"invitee_email_address": email,
|
|
||||||
"requested_user": requested_user,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except EmailSendingError as err:
|
|
||||||
domain_names = ", ".join([domain.name for domain in domains])
|
|
||||||
raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err
|
|
||||||
|
|
||||||
|
|
||||||
def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_invitation):
|
def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_invitation):
|
||||||
|
@ -227,6 +280,56 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_i
|
||||||
return all_admin_emails_sent
|
return all_admin_emails_sent
|
||||||
|
|
||||||
|
|
||||||
|
def send_portfolio_update_emails_to_portfolio_admins(editor, portfolio, updated_page):
|
||||||
|
"""
|
||||||
|
Sends an email notification to all portfolio admin when portfolio organization is updated.
|
||||||
|
|
||||||
|
Raises exceptions for validation or email-sending issues.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
editor (User): The user editing the portfolio organization.
|
||||||
|
portfolio (Portfolio): The portfolio object whose organization information is changed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Boolean indicating if all messages were sent successfully.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MissingEmailError: If the requestor has no email associated with their account.
|
||||||
|
EmailSendingError: If there is an error while sending the email.
|
||||||
|
"""
|
||||||
|
all_emails_sent = True
|
||||||
|
# Get each portfolio admin from list
|
||||||
|
user_portfolio_permissions = UserPortfolioPermission.objects.filter(
|
||||||
|
portfolio=portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
for user_portfolio_permission in user_portfolio_permissions:
|
||||||
|
# Send email to each portfolio_admin
|
||||||
|
user = user_portfolio_permission.user
|
||||||
|
try:
|
||||||
|
send_templated_email(
|
||||||
|
"emails/portfolio_org_update_notification.txt",
|
||||||
|
"emails/portfolio_org_update_notification_subject.txt",
|
||||||
|
to_address=user.email,
|
||||||
|
context={
|
||||||
|
"requested_user": user,
|
||||||
|
"portfolio": portfolio,
|
||||||
|
"editor": editor,
|
||||||
|
"portfolio_admin": user,
|
||||||
|
"date": date.today(),
|
||||||
|
"updated_info": updated_page,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except EmailSendingError:
|
||||||
|
logger.warning(
|
||||||
|
"Could not send email organization admin notification to %s " "for portfolio: %s",
|
||||||
|
user.email,
|
||||||
|
portfolio,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
all_emails_sent = False
|
||||||
|
return all_emails_sent
|
||||||
|
|
||||||
|
|
||||||
def send_portfolio_member_permission_update_email(requestor, permissions: UserPortfolioPermission):
|
def send_portfolio_member_permission_update_email(requestor, permissions: UserPortfolioPermission):
|
||||||
"""
|
"""
|
||||||
Sends an email notification to a portfolio member when their permissions are updated.
|
Sends an email notification to a portfolio member when their permissions are updated.
|
||||||
|
|
|
@ -68,7 +68,11 @@ from epplibwrapper import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..utility.email import send_templated_email, EmailSendingError
|
from ..utility.email import send_templated_email, EmailSendingError
|
||||||
from ..utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
|
from ..utility.email_invitations import (
|
||||||
|
send_domain_invitation_email,
|
||||||
|
send_domain_manager_removal_emails_to_domain_managers,
|
||||||
|
send_portfolio_invitation_email,
|
||||||
|
)
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -1474,48 +1478,17 @@ class DomainDeleteUserView(DeleteView):
|
||||||
super().form_valid(form)
|
super().form_valid(form)
|
||||||
|
|
||||||
# Email all domain managers that domain manager has been removed
|
# Email all domain managers that domain manager has been removed
|
||||||
domain = self.object.domain
|
send_domain_manager_removal_emails_to_domain_managers(
|
||||||
|
removed_by_user=self.request.user,
|
||||||
context = {
|
manager_removed=self.object.user,
|
||||||
"domain": domain,
|
manager_removed_email=self.object.user.email,
|
||||||
"removed_by": self.request.user,
|
domain=self.object.domain,
|
||||||
"manager_removed": self.object.user,
|
|
||||||
"date": date.today(),
|
|
||||||
"changes": "Domain Manager",
|
|
||||||
}
|
|
||||||
self.email_domain_managers(
|
|
||||||
domain,
|
|
||||||
"emails/domain_manager_deleted_notification.txt",
|
|
||||||
"emails/domain_manager_deleted_notification_subject.txt",
|
|
||||||
context,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add a success message
|
# Add a success message
|
||||||
messages.success(self.request, self.get_success_message())
|
messages.success(self.request, self.get_success_message())
|
||||||
return redirect(self.get_success_url())
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
|
|
||||||
manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
|
|
||||||
"user", flat=True
|
|
||||||
)
|
|
||||||
emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True))
|
|
||||||
|
|
||||||
for email in emails:
|
|
||||||
try:
|
|
||||||
send_templated_email(
|
|
||||||
template,
|
|
||||||
subject_template,
|
|
||||||
to_address=email,
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
except EmailSendingError:
|
|
||||||
logger.warning(
|
|
||||||
"Could not send notification email to %s for domain %s",
|
|
||||||
email,
|
|
||||||
domain.name,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Custom post implementation to ensure last userdomainrole is not removed and to
|
"""Custom post implementation to ensure last userdomainrole is not removed and to
|
||||||
redirect to home in the event that the user deletes themselves"""
|
redirect to home in the event that the user deletes themselves"""
|
||||||
|
|
|
@ -29,12 +29,14 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho
|
||||||
from registrar.utility.email import EmailSendingError
|
from registrar.utility.email import EmailSendingError
|
||||||
from registrar.utility.email_invitations import (
|
from registrar.utility.email_invitations import (
|
||||||
send_domain_invitation_email,
|
send_domain_invitation_email,
|
||||||
|
send_domain_manager_removal_emails_to_domain_managers,
|
||||||
send_portfolio_admin_addition_emails,
|
send_portfolio_admin_addition_emails,
|
||||||
send_portfolio_admin_removal_emails,
|
send_portfolio_admin_removal_emails,
|
||||||
send_portfolio_invitation_email,
|
send_portfolio_invitation_email,
|
||||||
send_portfolio_invitation_remove_email,
|
send_portfolio_invitation_remove_email,
|
||||||
send_portfolio_member_permission_remove_email,
|
send_portfolio_member_permission_remove_email,
|
||||||
send_portfolio_member_permission_update_email,
|
send_portfolio_member_permission_update_email,
|
||||||
|
send_portfolio_update_emails_to_portfolio_admins,
|
||||||
)
|
)
|
||||||
from registrar.utility.errors import MissingEmailError
|
from registrar.utility.errors import MissingEmailError
|
||||||
from registrar.utility.enums import DefaultUserValues
|
from registrar.utility.enums import DefaultUserValues
|
||||||
|
@ -193,6 +195,31 @@ class PortfolioMemberDeleteView(View):
|
||||||
messages.warning(
|
messages.warning(
|
||||||
request, f"Could not send email notification to {portfolio_member_permission.user.email}"
|
request, f"Could not send email notification to {portfolio_member_permission.user.email}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notify domain managers for domains which the member is being removed from
|
||||||
|
# Get list of portfolio domains that the member is invited to:
|
||||||
|
invited_domains = Domain.objects.filter(
|
||||||
|
invitations__email=portfolio_member_permission.user.email,
|
||||||
|
domain_info__portfolio=portfolio_member_permission.portfolio,
|
||||||
|
invitations__status=DomainInvitation.DomainInvitationStatus.INVITED,
|
||||||
|
).distinct()
|
||||||
|
# Get list of portfolio domains that the member is a manager of
|
||||||
|
domains = Domain.objects.filter(
|
||||||
|
permissions__user=portfolio_member_permission.user,
|
||||||
|
domain_info__portfolio=portfolio_member_permission.portfolio,
|
||||||
|
).distinct()
|
||||||
|
# Combine both querysets while ensuring uniqueness
|
||||||
|
all_domains = domains.union(invited_domains)
|
||||||
|
for domain in all_domains:
|
||||||
|
if not send_domain_manager_removal_emails_to_domain_managers(
|
||||||
|
removed_by_user=request.user,
|
||||||
|
manager_removed=portfolio_member_permission.user,
|
||||||
|
manager_removed_email=portfolio_member_permission.user.email,
|
||||||
|
domain=domain,
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
request, "Could not send email notification to existing domain managers for %s", domain
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._handle_exceptions(e)
|
self._handle_exceptions(e)
|
||||||
|
|
||||||
|
@ -432,6 +459,20 @@ class PortfolioMemberDomainsEditView(DetailView, View):
|
||||||
Processes removed domains by deleting corresponding UserDomainRole instances.
|
Processes removed domains by deleting corresponding UserDomainRole instances.
|
||||||
"""
|
"""
|
||||||
if removed_domain_ids:
|
if removed_domain_ids:
|
||||||
|
# Notify domain managers for domains which the member is being removed from
|
||||||
|
# Fetch Domain objects from removed_domain_ids
|
||||||
|
removed_domains = Domain.objects.filter(id__in=removed_domain_ids)
|
||||||
|
# need to get the domains from removed_domain_ids
|
||||||
|
for domain in removed_domains:
|
||||||
|
if not send_domain_manager_removal_emails_to_domain_managers(
|
||||||
|
removed_by_user=self.request.user,
|
||||||
|
manager_removed=member,
|
||||||
|
manager_removed_email=member.email,
|
||||||
|
domain=domain,
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
self.request, "Could not send email notification to existing domain managers for %s", domain
|
||||||
|
)
|
||||||
# Delete UserDomainRole instances for removed domains
|
# Delete UserDomainRole instances for removed domains
|
||||||
UserDomainRole.objects.filter(domain_id__in=removed_domain_ids, user=member).delete()
|
UserDomainRole.objects.filter(domain_id__in=removed_domain_ids, user=member).delete()
|
||||||
|
|
||||||
|
@ -502,6 +543,31 @@ class PortfolioInvitedMemberDeleteView(View):
|
||||||
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
if not send_portfolio_invitation_remove_email(requestor=request.user, invitation=portfolio_invitation):
|
if not send_portfolio_invitation_remove_email(requestor=request.user, invitation=portfolio_invitation):
|
||||||
messages.warning(request, f"Could not send email notification to {portfolio_invitation.email}")
|
messages.warning(request, f"Could not send email notification to {portfolio_invitation.email}")
|
||||||
|
|
||||||
|
# Notify domain managers for domains which the invited member is being removed from
|
||||||
|
# Get list of portfolio domains that the invited member is invited to:
|
||||||
|
invited_domains = Domain.objects.filter(
|
||||||
|
invitations__email=portfolio_invitation.email,
|
||||||
|
domain_info__portfolio=portfolio_invitation.portfolio,
|
||||||
|
invitations__status=DomainInvitation.DomainInvitationStatus.INVITED,
|
||||||
|
).distinct()
|
||||||
|
# Get list of portfolio domains that the member is a manager of
|
||||||
|
domains = Domain.objects.filter(
|
||||||
|
permissions__user__email=portfolio_invitation.email,
|
||||||
|
domain_info__portfolio=portfolio_invitation.portfolio,
|
||||||
|
).distinct()
|
||||||
|
# Combine both querysets while ensuring uniqueness
|
||||||
|
all_domains = domains.union(invited_domains)
|
||||||
|
for domain in all_domains:
|
||||||
|
if not send_domain_manager_removal_emails_to_domain_managers(
|
||||||
|
removed_by_user=request.user,
|
||||||
|
manager_removed=None,
|
||||||
|
manager_removed_email=portfolio_invitation.email,
|
||||||
|
domain=domain,
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
request, "Could not send email notification to existing domain managers for %s", domain
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._handle_exceptions(e)
|
self._handle_exceptions(e)
|
||||||
|
|
||||||
|
@ -740,6 +806,21 @@ class PortfolioInvitedMemberDomainsEditView(DetailView, View):
|
||||||
if not removed_domain_ids:
|
if not removed_domain_ids:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Notify domain managers for domains which the member is being removed from
|
||||||
|
# Fetch Domain objects from removed_domain_ids
|
||||||
|
removed_domains = Domain.objects.filter(id__in=removed_domain_ids)
|
||||||
|
# need to get the domains from removed_domain_ids
|
||||||
|
for domain in removed_domains:
|
||||||
|
if not send_domain_manager_removal_emails_to_domain_managers(
|
||||||
|
removed_by_user=self.request.user,
|
||||||
|
manager_removed=None,
|
||||||
|
manager_removed_email=email,
|
||||||
|
domain=domain,
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
self.request, "Could not send email notification to existing domain managers for %s", domain
|
||||||
|
)
|
||||||
|
|
||||||
# Update invitations from INVITED to CANCELED
|
# Update invitations from INVITED to CANCELED
|
||||||
DomainInvitation.objects.filter(
|
DomainInvitation.objects.filter(
|
||||||
domain_id__in=removed_domain_ids,
|
domain_id__in=removed_domain_ids,
|
||||||
|
@ -850,6 +931,20 @@ class PortfolioOrganizationView(DetailView, FormMixin):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
user = request.user
|
||||||
|
try:
|
||||||
|
if not send_portfolio_update_emails_to_portfolio_admins(
|
||||||
|
editor=user, portfolio=self.request.session.get("portfolio"), updated_page="Organization"
|
||||||
|
):
|
||||||
|
messages.warning(self.request, "Could not send email notification to all organization admins.")
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"An unexpected error occurred: {str(e)}. If the issue persists, "
|
||||||
|
f"please contact {DefaultUserValues.HELP_EMAIL}.",
|
||||||
|
)
|
||||||
|
logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True)
|
||||||
|
return None
|
||||||
return self.form_valid(form)
|
return self.form_valid(form)
|
||||||
else:
|
else:
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
@ -902,6 +997,45 @@ class PortfolioSeniorOfficialView(DetailView, FormMixin):
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
return self.render_to_response(self.get_context_data(form=form))
|
return self.render_to_response(self.get_context_data(form=form))
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Handle POST requests to process form submission."""
|
||||||
|
self.object = self.get_object()
|
||||||
|
form = self.get_form()
|
||||||
|
if form.is_valid():
|
||||||
|
user = request.user
|
||||||
|
try:
|
||||||
|
if not send_portfolio_update_emails_to_portfolio_admins(
|
||||||
|
editor=user, portfolio=self.request.session.get("portfolio"), updated_page="Senior Official"
|
||||||
|
):
|
||||||
|
messages.warning(self.request, "Could not send email notification to all organization admins.")
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"An unexpected error occurred: {str(e)}. If the issue persists, "
|
||||||
|
f"please contact {DefaultUserValues.HELP_EMAIL}.",
|
||||||
|
)
|
||||||
|
logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True)
|
||||||
|
return None
|
||||||
|
return self.form_valid(form)
|
||||||
|
else:
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""Handle the case when the form is valid."""
|
||||||
|
self.object = form.save(commit=False)
|
||||||
|
self.object.creator = self.request.user
|
||||||
|
self.object.save()
|
||||||
|
messages.success(self.request, "The senior official information for this portfolio has been updated.")
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
"""Handle the case when the form is invalid."""
|
||||||
|
return self.render_to_response(self.get_context_data(form=form))
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
"""Redirect to the overview page for the portfolio."""
|
||||||
|
return reverse("senior-official")
|
||||||
|
|
||||||
|
|
||||||
@grant_access(HAS_PORTFOLIO_MEMBERS_ANY_PERM)
|
@grant_access(HAS_PORTFOLIO_MEMBERS_ANY_PERM)
|
||||||
class PortfolioMembersView(View):
|
class PortfolioMembersView(View):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue