Date: Thu, 19 Dec 2024 19:51:25 -0500
Subject: [PATCH 035/114] cleanup and comments
---
src/registrar/admin.py | 25 ++++++++++++++++++-------
src/registrar/forms/portfolio.py | 29 ++++++++++++++++++-----------
src/registrar/models/portfolio.py | 7 -------
3 files changed, 36 insertions(+), 25 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 2707a1dff..168c27c01 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1486,7 +1486,11 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
def save_model(self, request, obj, form, change):
"""
- Override the save_model method to send an email only on creation of the PortfolioInvitation object.
+ Override the save_model method.
+
+ Only send email on creation of the PortfolioInvitation object. Not on updates.
+ Emails sent to requested user / email.
+ When exceptions are raised, return without saving model.
"""
if not change: # Only send email if this is a new PortfolioInvitation(creation)
portfolio = obj.portfolio
@@ -1499,19 +1503,23 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
).exists()
try:
if not requested_user or not permission_exists:
+ # if requested user does not exist or permission does not exist, send email
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
messages.success(request, f"{requested_email} has been invited.")
- else:
- if permission_exists:
- messages.warning(request, "User is already a member of this portfolio.")
+ elif permission_exists:
+ messages.warning(request, "User is already a member of this portfolio.")
except Exception as e:
+ # when exception is raised, handle and do not save the model
self._handle_exceptions(e, request, obj)
return
# Call the parent save method to save the object
super().save_model(request, obj, form, change)
def _handle_exceptions(self, exception, request, obj):
- """Handle exceptions raised during the process."""
+ """Handle exceptions raised during the process.
+
+ Log warnings / errors, and message errors to the user.
+ """
if isinstance(exception, EmailSendingError):
logger.warning(
"Could not sent email invitation to %s for portfolio %s (EmailSendingError)",
@@ -1534,7 +1542,10 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
def response_add(self, request, obj, post_url_continue=None):
"""
- Override response_add to handle redirection when exceptions are raised.
+ Override response_add to handle rendering when exceptions are raised during add model.
+
+ Normal flow on successful save_model on add is to redirect to changelist_view.
+ If there are errors, flow is modified to instead render change form.
"""
# Check if there are any error or warning messages in the `messages` framework
storage = get_messages(request)
@@ -1576,7 +1587,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
"obj": obj,
"adminform": admin_form, # Pass the AdminForm instance
"media": media,
- "errors": None, # You can use this to pass custom form errors
+ "errors": None,
}
return self.render_change_form(
request,
diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py
index b055985d1..0a8c4d623 100644
--- a/src/registrar/forms/portfolio.py
+++ b/src/registrar/forms/portfolio.py
@@ -113,6 +113,7 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
class BasePortfolioMemberForm(forms.ModelForm):
"""Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm"""
+ # The label for each of these has a red "required" star. We can just embed that here for simplicity.
required_star = '*'
role = forms.ChoiceField(
choices=[
@@ -167,6 +168,9 @@ class BasePortfolioMemberForm(forms.ModelForm):
},
)
+ # Tracks what form elements are required for a given role choice.
+ # All of the fields included here have "required=False" by default as they are conditionally required.
+ # see def clean() for more details.
ROLE_REQUIRED_FIELDS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
"domain_request_permission_admin",
@@ -183,10 +187,13 @@ class BasePortfolioMemberForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
"""
- Override the form's initialization to map existing model values
- to custom form fields.
+ Override the form's initialization.
+
+ Map existing model values to custom form fields.
+ Update field descriptions.
"""
super().__init__(*args, **kwargs)
+ # Adds a description beneath each role option
self.fields["role"].descriptions = {
"organization_admin": UserPortfolioRoleChoices.get_role_description(
UserPortfolioRoleChoices.ORGANIZATION_ADMIN
@@ -196,14 +203,14 @@ class BasePortfolioMemberForm(forms.ModelForm):
),
}
# Map model instance values to custom form fields
- logger.info(self.instance)
- logger.info(self.initial)
if self.instance:
self.map_instance_to_initial()
def clean(self):
- """Validates form data based on selected role and its required fields."""
- logger.info("clean")
+ """Validates form data based on selected role and its required fields.
+ Updates roles and additional_permissions in cleaned_data so they can be properly
+ mapped to the model.
+ """
cleaned_data = super().clean()
role = cleaned_data.get("role")
@@ -239,13 +246,12 @@ class BasePortfolioMemberForm(forms.ModelForm):
role_permissions = UserPortfolioPermission.get_portfolio_permissions(cleaned_data["roles"], [], get_list=False)
cleaned_data["additional_permissions"] = list(additional_permissions - role_permissions)
- logger.info(cleaned_data)
return cleaned_data
def map_instance_to_initial(self):
"""
Maps self.instance to self.initial, handling roles and permissions.
- Returns form data dictionary with appropriate permission levels based on user role:
+ Updates self.initial dictionary with appropriate permission levels based on user role:
{
"role": "organization_admin" or "organization_member",
"member_permission_admin": permission level if admin,
@@ -253,10 +259,9 @@ class BasePortfolioMemberForm(forms.ModelForm):
"domain_request_permission_member": permission level if member
}
"""
- logger.info(self.instance)
- # Function variables
if self.initial is None:
self.initial = {}
+ # Function variables
perms = UserPortfolioPermission.get_portfolio_permissions(
self.instance.roles, self.instance.additional_permissions, get_list=False
)
@@ -290,7 +295,6 @@ class BasePortfolioMemberForm(forms.ModelForm):
# Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection.
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access")
self.initial["domain_request_permission_member"] = selected_domain_permission
- logger.info(self.initial)
class PortfolioMemberForm(BasePortfolioMemberForm):
@@ -314,6 +318,9 @@ class PortfolioInvitedMemberForm(BasePortfolioMemberForm):
class PortfolioNewMemberForm(BasePortfolioMemberForm):
+ """
+ Form for adding a portfolio invited member.
+ """
email = forms.EmailField(
label="Enter the email of the member you'd like to invite",
diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py
index e7730230e..82afcd4d6 100644
--- a/src/registrar/models/portfolio.py
+++ b/src/registrar/models/portfolio.py
@@ -6,9 +6,6 @@ from registrar.models.user import User
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from .utility.time_stamped_model import TimeStampedModel
-import logging
-
-logger = logging.getLogger(__name__)
class Portfolio(TimeStampedModel):
@@ -166,7 +163,3 @@ class Portfolio(TimeStampedModel):
def get_suborganizations(self):
"""Returns all suborganizations associated with this portfolio"""
return self.portfolio_suborganizations.all()
-
- def full_clean(self, exclude=None, validate_unique=True):
- logger.info("portfolio full clean")
- super().full_clean(exclude, validate_unique)
From 5d0301567de6054cde2bc1847727fb62f5a5e3bc Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 19 Dec 2024 21:18:45 -0500
Subject: [PATCH 036/114] account for the members table
---
src/registrar/assets/src/sass/_theme/_accordions.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss
index 261492dd5..ac3025028 100644
--- a/src/registrar/assets/src/sass/_theme/_accordions.scss
+++ b/src/registrar/assets/src/sass/_theme/_accordions.scss
@@ -41,7 +41,7 @@
}
// Special positioning for the kabob menu popup in the last row on a given page
-tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
+tr:last-of-type:not(.show-more-content) .usa-accordion--more-actions .usa-accordion__content {
top: auto;
bottom: -10px;
right: 30px;
From 01b01ba696297cf5f1e7acc5c8ec61ea4ed59387 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 19 Dec 2024 21:23:38 -0500
Subject: [PATCH 037/114] account for the collapsed / uncollapsed content
---
src/registrar/assets/src/sass/_theme/_accordions.scss | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss
index ac3025028..0afe03e81 100644
--- a/src/registrar/assets/src/sass/_theme/_accordions.scss
+++ b/src/registrar/assets/src/sass/_theme/_accordions.scss
@@ -41,7 +41,8 @@
}
// Special positioning for the kabob menu popup in the last row on a given page
-tr:last-of-type:not(.show-more-content) .usa-accordion--more-actions .usa-accordion__content {
+// Account for the Members table rows with the collapsed expandable content
+tr:last-of-type:not(.show-more-content.display-none) .usa-accordion--more-actions .usa-accordion__content {
top: auto;
bottom: -10px;
right: 30px;
From 2386b38cc2229e07492331d1d5b1f4423caa9c4f Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 19 Dec 2024 21:55:50 -0500
Subject: [PATCH 038/114] cleanup
---
src/registrar/assets/src/sass/_theme/_accordions.scss | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss
index 0afe03e81..762618415 100644
--- a/src/registrar/assets/src/sass/_theme/_accordions.scss
+++ b/src/registrar/assets/src/sass/_theme/_accordions.scss
@@ -41,8 +41,10 @@
}
// Special positioning for the kabob menu popup in the last row on a given page
-// Account for the Members table rows with the collapsed expandable content
-tr:last-of-type:not(.show-more-content.display-none) .usa-accordion--more-actions .usa-accordion__content {
+// This won't work on the Members table rows because that table has show-more rows
+// Currently, that's not an issue since that Members table is not wrapped in the
+// reponsive wrapper.
+tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
top: auto;
bottom: -10px;
right: 30px;
From 9cb3ecebf4952c9779bc444ce9fb2e742d3cbf2e Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 20 Dec 2024 10:08:00 -0700
Subject: [PATCH 039/114] Cleanup js after merge
---
.../src/js/getgov-admin/domain-request-form.js | 15 +++++++++++++--
.../helpers-portfolio-dynamic-fields.js | 12 +++++++++++-
2 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js
index 242ae25c8..928495cb6 100644
--- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js
+++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js
@@ -629,7 +629,18 @@ export function initRejectedEmail() {
});
}
-function handleSuborganizationSelection() {
+
+/**
+ * A function that handles the suborganzation and requested suborganization fields and buttons.
+ * - Fieldwise: Hooks to the sub_organization, suborganization_city, and suborganization_state_territory fields.
+ * On change, this function checks if any of these fields are not empty:
+ * sub_organization, suborganization_city, and suborganization_state_territory.
+ * If they aren't, then we show the "clear" button. If they are, then we hide it because we don't need it.
+ *
+ * - Buttonwise: Hooks to the #clear-requested-suborganization button.
+ * On click, this will clear the input value of sub_organization, suborganization_city, and suborganization_state_territory.
+*/
+function handleSuborgFieldsAndButtons() {
const requestedSuborganizationField = document.getElementById("id_requested_suborganization");
const suborganizationCity = document.getElementById("id_suborganization_city");
const suborganizationStateTerritory = document.getElementById("id_suborganization_state_territory");
@@ -672,6 +683,6 @@ export function initDynamicDomainRequestFields(){
const domainRequestPage = document.getElementById("domainrequest_form");
if (domainRequestPage) {
handlePortfolioSelection();
- handleSuborganizationSelection();
+ handleSuborgFieldsAndButtons();
}
}
diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js
index 91fd2b6e3..ce5db34c1 100644
--- a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js
+++ b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js
@@ -471,6 +471,16 @@ export function handlePortfolioSelection(
if (suborganizationCity) showElement(suborganizationCity);
if (suborganizationStateTerritory) showElement(suborganizationStateTerritory);
+ // Handle rejectSuborganizationButtonFieldset (display of the clear requested suborg button).
+ // Basically, this button should only be visible when we have data for suborg, city, and state_territory.
+ // The function handleSuborgFieldsAndButtons() in domain-request-form.js handles doing this same logic
+ // but on field input for city, state_territory, and the suborg field.
+ // If it doesn't exist, don't do anything.
+ if (!rejectSuborganizationButtonFieldset){
+ console.warn("updateSuborganizationFieldsDisplay() => Could not update rejectSuborganizationButtonFieldset")
+ return;
+ }
+
// Initially show / hide the clear button only if there is data to clear
let requestedSuborganizationField = document.getElementById("id_requested_suborganization");
let suborganizationCity = document.getElementById("id_suborganization_city");
@@ -489,7 +499,7 @@ export function handlePortfolioSelection(
if (requestedSuborganizationField) hideElement(requestedSuborganizationField);
if (suborganizationCity) hideElement(suborganizationCity);
if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory);
- hideElement(rejectSuborganizationButtonFieldset);
+ if (rejectSuborganizationButtonFieldset) hideElement(rejectSuborganizationButtonFieldset);
}
}
From 3d6d9b0d2fd2672a44293be9f40316b0b64b5d16 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 20 Dec 2024 10:28:10 -0700
Subject: [PATCH 040/114] Update domain_request.py
---
src/registrar/models/domain_request.py | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index ff3af829b..381d28703 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -673,6 +673,16 @@ class DomainRequest(TimeStampedModel):
self._cache_status_and_status_reasons()
def clean(self):
+ """
+ Validates suborganization-related fields in two scenarios:
+ 1. New suborganization request: Prevents duplicate names within same portfolio
+ 2. Partial suborganization data: Enforces a all-or-nothing rule for city/state/name fields
+ when portfolio exists without selected suborganization
+
+ Add new domain request validation rules here to ensure they're
+ enforced during both model save and form submission.
+ Not presently used on the domain request wizard, though.
+ """
super().clean()
# Validation logic for a suborganization request
if self.is_requesting_new_suborganization():
From 19a1d60c13034052293467636a56ee41ccb0d089 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 20 Dec 2024 10:43:20 -0700
Subject: [PATCH 041/114] Update test_admin_request.py
---
src/registrar/tests/test_admin_request.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py
index df0902719..9bdc1fbb4 100644
--- a/src/registrar/tests/test_admin_request.py
+++ b/src/registrar/tests/test_admin_request.py
@@ -1644,6 +1644,7 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains",
"is_election_board",
"status_history",
+ "reject_suborganization_button",
"id",
"created_at",
"updated_at",
@@ -1718,6 +1719,7 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains",
"is_election_board",
"status_history",
+ "reject_suborganization_button",
"federal_agency",
"creator",
"about_your_organization",
@@ -1761,6 +1763,7 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains",
"is_election_board",
"status_history",
+ "reject_suborganization_button",
]
self.assertEqual(readonly_fields, expected_fields)
From 5d601d69913143a78399c545c1ad669ace385454 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 20 Dec 2024 11:30:21 -0700
Subject: [PATCH 042/114] linting
---
src/registrar/forms/domain_request_wizard.py | 16 ++++++++--------
src/registrar/models/domain_request.py | 9 +++------
src/registrar/tests/test_reports.py | 1 +
src/registrar/utility/csv_export.py | 2 +-
4 files changed, 13 insertions(+), 15 deletions(-)
diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py
index d84ceeb15..5eea13ed8 100644
--- a/src/registrar/forms/domain_request_wizard.py
+++ b/src/registrar/forms/domain_request_wizard.py
@@ -80,17 +80,17 @@ class RequestingEntityForm(RegistrarForm):
return self.cleaned_data.get("sub_organization")
def clean_requested_suborganization(self):
- name = self.cleaned_data.get('requested_suborganization')
- if name and Suborganization.objects.filter(
- name__iexact=name,
- portfolio=self.domain_request.portfolio,
- name__isnull=False,
- portfolio__isnull=False
- ).exists():
+ name = self.cleaned_data.get("requested_suborganization")
+ if (
+ name
+ and Suborganization.objects.filter(
+ name__iexact=name, portfolio=self.domain_request.portfolio, name__isnull=False, portfolio__isnull=False
+ ).exists()
+ ):
raise ValidationError(
"This suborganization already exists. "
"Choose a new name, or select it directly if you would like to use it."
- )
+ )
return name
def full_clean(self):
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index 381d28703..f4e2a35a1 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -679,9 +679,9 @@ class DomainRequest(TimeStampedModel):
2. Partial suborganization data: Enforces a all-or-nothing rule for city/state/name fields
when portfolio exists without selected suborganization
- Add new domain request validation rules here to ensure they're
+ Add new domain request validation rules here to ensure they're
enforced during both model save and form submission.
- Not presently used on the domain request wizard, though.
+ Not presently used on the domain request wizard, though.
"""
super().clean()
# Validation logic for a suborganization request
@@ -717,10 +717,7 @@ class DomainRequest(TimeStampedModel):
if not value
}
# Adds a validation error to each missing field
- raise ValidationError({
- k: ValidationError(v, code='required')
- for k, v in errors_dict.items()
- })
+ raise ValidationError({k: ValidationError(v, code="required") for k, v in errors_dict.items()})
def save(self, *args, **kwargs):
"""Save override for custom properties"""
diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py
index 995782eea..64cfa95b0 100644
--- a/src/registrar/tests/test_reports.py
+++ b/src/registrar/tests/test_reports.py
@@ -869,6 +869,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
csv_file.seek(0)
# Read the content into a variable
csv_content = csv_file.read()
+ self.maxDiff = None
expected_content = (
# Header
"Email,Organization admin,Invited by,Joined date,Last active,Domain requests,"
diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py
index 66809777b..f05f14d99 100644
--- a/src/registrar/utility/csv_export.py
+++ b/src/registrar/utility/csv_export.py
@@ -417,7 +417,7 @@ class MemberExport(BaseExport):
# Adding a order_by increases output predictability.
# Doesn't matter as much for normal use, but makes tests easier.
# We should also just be ordering by default anyway.
- members = permissions.union(invitations).order_by("email_display")
+ members = permissions.union(invitations).order_by("email_display", "member_display", "first_name", "last_name")
return convert_queryset_to_dict(members, is_model=False)
@classmethod
From d27352b599515c00f74a0eab8ac7528735666023 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Fri, 20 Dec 2024 15:58:45 -0500
Subject: [PATCH 043/114] Fix broken tests
---
src/registrar/tests/test_views_domain.py | 66 +++++++--------------
src/registrar/tests/test_views_portfolio.py | 65 ++++++++++++--------
src/registrar/views/domain.py | 3 +-
src/registrar/views/portfolios.py | 3 +-
4 files changed, 65 insertions(+), 72 deletions(-)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 25e8b0fb6..3f1e85dfa 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -721,39 +721,20 @@ class TestDomainManagers(TestDomainOverview):
self.assertNotIn("Last", email_content)
self.assertNotIn("First Last", email_content)
- @boto3_mocking.patching
@less_console_noise_decorator
- def test_domain_invitation_email_displays_error_non_existent(self):
- """Inviting a non existent user sends them an email, with email as the name."""
- # make sure there is no user with this email
- email_address = "mayor@igorville.gov"
- User.objects.filter(email=email_address).delete()
-
- # Give the user who is sending the email an invalid email address
- self.user.email = ""
- self.user.save()
-
+ def test_domain_invitation_email_validation_blocks_bad_email(self):
+ """Inviting a bad email blocks at validation."""
+ email_address = "mayor"
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
- mock_client = MagicMock()
- mock_error_message = MagicMock()
- with boto3_mocking.clients.handler_for("sesv2", mock_client):
- with patch("django.contrib.messages.error") as mock_error_message:
- add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
- session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
- add_page.form["email"] = email_address
- self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
- add_page.form.submit().follow()
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+ add_page.form["email"] = email_address
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ response = add_page.form.submit()
- expected_message_content = "Can't send invitation email. No email is associated with your account."
+ self.assertContains(response, "Enter an email address in the required format, like name@example.com.")
- # Grab the message content
- returned_error_message = mock_error_message.call_args[0][1]
-
- # Check that the message content is what we expect
- self.assertEqual(expected_message_content, returned_error_message)
-
- @boto3_mocking.patching
@less_console_noise_decorator
def test_domain_invitation_email_displays_error(self):
"""When the requesting user has no email, an error is displayed"""
@@ -764,28 +745,25 @@ class TestDomainManagers(TestDomainOverview):
# Give the user who is sending the email an invalid email address
self.user.email = ""
+ self.user.is_staff = False
self.user.save()
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
- mock_client = MagicMock()
+ with patch("django.contrib.messages.error") as mock_error:
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+ add_page.form["email"] = email_address
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ add_page.form.submit()
- mock_error_message = MagicMock()
- with boto3_mocking.clients.handler_for("sesv2", mock_client):
- with patch("django.contrib.messages.error") as mock_error_message:
- add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
- session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
- add_page.form["email"] = email_address
- self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
- add_page.form.submit().follow()
+ expected_message_content = f"Can't send invitation email. No email is associated with the account for 'test_user'."
- expected_message_content = "Can't send invitation email. No email is associated with your account."
-
- # Grab the message content
- returned_error_message = mock_error_message.call_args[0][1]
-
- # Check that the message content is what we expect
- self.assertEqual(expected_message_content, returned_error_message)
+ # Assert that the error message was called with the correct argument
+ mock_error.assert_called_once_with(
+ ANY,
+ expected_message_content,
+ )
@less_console_noise_decorator
def test_domain_invitation_cancel(self):
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 01383ae77..f92bb59ab 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -2533,6 +2533,8 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
cls.new_member_email = "new_user@example.com"
+ AllowedEmail.objects.get_or_create(email=cls.new_member_email)
+
# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
user=cls.user,
@@ -2550,8 +2552,10 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
+ AllowedEmail.objects.all().delete()
super().tearDownClass()
+ @boto3_mocking.patching
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@@ -2563,25 +2567,28 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
- # Simulate submission of member invite for new user
- final_response = self.client.post(
- reverse("new-member"),
- {
- "member_access_level": "basic",
- "basic_org_domain_request_permissions": "view_only",
- "email": self.new_member_email,
- },
- )
+ mock_client = MagicMock()
- # Ensure the final submission is successful
- self.assertEqual(final_response.status_code, 302) # redirects after success
+ with boto3_mocking.clients.handler_for("sesv2", mock_client):
+ # Simulate submission of member invite for new user
+ final_response = self.client.post(
+ reverse("new-member"),
+ {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ "email": self.new_member_email,
+ },
+ )
- # Validate Database Changes
- portfolio_invite = PortfolioInvitation.objects.filter(
- email=self.new_member_email, portfolio=self.portfolio
- ).first()
- self.assertIsNotNone(portfolio_invite)
- self.assertEqual(portfolio_invite.email, self.new_member_email)
+ # Ensure the final submission is successful
+ self.assertEqual(final_response.status_code, 302) # Redirects
+
+ # Validate Database Changes
+ portfolio_invite = PortfolioInvitation.objects.filter(
+ email=self.new_member_email, portfolio=self.portfolio
+ ).first()
+ self.assertIsNotNone(portfolio_invite)
+ self.assertEqual(portfolio_invite.email, self.new_member_email)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@@ -2600,14 +2607,15 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
response = self.client.post(
reverse("new-member"),
{
- "member_access_level": "basic",
- "basic_org_domain_request_permissions": "view_only",
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.invited_member_email,
},
)
- self.assertEqual(response.status_code, 302) # Redirects
+ self.assertEqual(response.status_code, 200)
- # TODO: verify messages
+ # verify messages
+ self.assertContains(response, "This user is already assigned to a portfolio invitation. Based on current waffle flag settings, users cannot be assigned to multiple portfolios.")
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
@@ -2630,14 +2638,15 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
response = self.client.post(
reverse("new-member"),
{
- "member_access_level": "basic",
- "basic_org_domain_request_permissions": "view_only",
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.user.email,
},
)
- self.assertEqual(response.status_code, 302) # Redirects
+ self.assertEqual(response.status_code, 200)
- # TODO: verify messages
+ # Verify messages
+ self.assertContains(response, "This user is already assigned to a portfolio. Based on current waffle flag settings, users cannot be assigned to multiple portfolios.")
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
@@ -2783,7 +2792,11 @@ class TestEditPortfolioMemberView(WebTest):
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_admin_removing_own_admin_role(self):
- """Tests an admin removing their own admin role redirects to home."""
+ """Tests an admin removing their own admin role redirects to home.
+
+ Removing the admin role will remove both view and edit members permissions.
+ Note: The user can remove the edit members permissions but as long as they stay in admin role, they will at least still have view members permissions."""
+
self.client.force_login(self.user)
# Get the user's admin permission
diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py
index 60fb9b7b1..f17acb820 100644
--- a/src/registrar/views/domain.py
+++ b/src/registrar/views/domain.py
@@ -1254,6 +1254,8 @@ class DomainAddUserView(DomainFormBaseView):
messages.success(self.request, f"{requested_email} has been invited.")
except Exception as e:
self._handle_portfolio_exceptions(e, requested_email, requestor_org)
+ # If that first invite does not succeed take an early exit
+ return redirect(self.get_success_url())
try:
if requested_user is None:
@@ -1288,7 +1290,6 @@ class DomainAddUserView(DomainFormBaseView):
send_domain_invitation_email(
email=email,
requestor=requestor,
- requested_user=requested_user,
domain=self.object,
is_member_of_different_org=member_of_different_org,
)
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index bce668665..f5c16e2ac 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -163,13 +163,14 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
def post(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
+ user_initially_is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
user = portfolio_permission.user
form = self.form_class(request.POST, instance=portfolio_permission)
if form.is_valid():
# Check if user is removing their own admin or edit role
removing_admin_role_on_self = (
request.user == user
- and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
+ and user_initially_is_admin
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", [])
)
form.save()
From 1b16d6dbbf80d80c1bd6fbb8f74a3f137f459c89 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 20 Dec 2024 19:22:54 -0500
Subject: [PATCH 044/114] portfolio views tests
---
src/registrar/tests/test_views_portfolio.py | 230 +++++++++++++++++++-
src/registrar/views/portfolios.py | 2 +-
2 files changed, 226 insertions(+), 6 deletions(-)
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index f92bb59ab..d1c533489 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -19,6 +19,9 @@ from registrar.models.user_group import UserGroup
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.tests.test_views import TestWithUser
+from registrar.utility.email import EmailSendingError
+from registrar.utility.email_invitations import send_portfolio_invitation_email
+from registrar.utility.errors import MissingEmailError
from .common import MockSESClient, completed_domain_request, create_test_user, create_user
from waffle.testutils import override_flag
from django.contrib.sessions.middleware import SessionMiddleware
@@ -2531,7 +2534,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
],
)
- cls.new_member_email = "new_user@example.com"
+ cls.new_member_email = "davekenn4242@gmail.com"
AllowedEmail.objects.get_or_create(email=cls.new_member_email)
@@ -2567,9 +2570,10 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
- mock_client = MagicMock()
+ mock_client_class = MagicMock()
+ mock_client = mock_client_class.return_value
- with boto3_mocking.clients.handler_for("sesv2", mock_client):
+ with boto3_mocking.clients.handler_for("sesv2", mock_client_class):
# Simulate submission of member invite for new user
final_response = self.client.post(
reverse("new-member"),
@@ -2590,10 +2594,219 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
self.assertIsNotNone(portfolio_invite)
self.assertEqual(portfolio_invite.email, self.new_member_email)
+ # Check that an email was sent
+ self.assertTrue(mock_client.send_email.called)
+
+ @boto3_mocking.patching
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
- def test_member_invite_for_previously_invited_member(self):
+ def test_member_invite_for_new_users_initial_ajax_call_passes(self):
+ """Tests the member invitation flow for new users."""
+ self.client.force_login(self.user)
+
+ # Simulate a session to ensure continuity
+ session_id = self.client.session.session_key
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+ mock_client_class = MagicMock()
+ mock_client = mock_client_class.return_value
+
+ with boto3_mocking.clients.handler_for("sesv2", mock_client_class):
+ # Simulate submission of member invite for new user
+ final_response = self.client.post(
+ reverse("new-member"),
+ {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ "email": self.new_member_email,
+ },
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+
+ # Ensure the prep ajax submission is successful
+ self.assertEqual(final_response.status_code, 200)
+
+ # Check that the response is a JSON response with is_valid
+ json_response = final_response.json()
+ self.assertIn("is_valid", json_response)
+ self.assertTrue(json_response["is_valid"])
+
+ # assert that portfolio invitation is not created
+ self.assertFalse(
+ PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
+ "Portfolio invitation should not be created when an Exception occurs."
+ )
+
+ # Check that an email was not sent
+ self.assertFalse(mock_client.send_email.called)
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ @patch("registrar.views.portfolios.send_portfolio_invitation_email")
+ def test_member_invite_for_previously_invited_member_initial_ajax_call_fails(self, mock_send_email):
+ """Tests the initial ajax call in the member invitation flow for existing portfolio member."""
+ self.client.force_login(self.user)
+
+ # Simulate a session to ensure continuity
+ session_id = self.client.session.session_key
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+ invite_count_before = PortfolioInvitation.objects.count()
+
+ # Simulate submission of member invite for user who has already been invited
+ response = self.client.post(
+ reverse("new-member"),
+ {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ "email": self.invited_member_email,
+ },
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+ self.assertEqual(response.status_code, 200)
+
+ # Check that the response is a JSON response with is_valid == False
+ json_response = response.json()
+ self.assertIn("is_valid", json_response)
+ self.assertFalse(json_response["is_valid"])
+
+ # Validate Database has not changed
+ invite_count_after = PortfolioInvitation.objects.count()
+ self.assertEqual(invite_count_after, invite_count_before)
+
+ # assert that send_portfolio_invitation_email is not called
+ mock_send_email.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_invitation_email")
+ def test_submit_new_member_raises_email_sending_error(self, mock_send_email):
+ """Test when adding a new member and email_send method raises EmailSendingError."""
+ mock_send_email.side_effect = EmailSendingError("Failed to send email.")
+
+ self.client.force_login(self.user)
+
+ # Simulate a session to ensure continuity
+ session_id = self.client.session.session_key
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+ form_data = {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ "email": self.new_member_email,
+ }
+
+ # Act
+ with patch("django.contrib.messages.warning") as mock_warning:
+ response = self.client.post(reverse("new-member"), data=form_data)
+
+ # Assert
+ # assert that the send_portfolio_invitation_email called
+ mock_send_email.assert_called_once_with(
+ email=self.new_member_email, requestor=self.user, portfolio=self.portfolio
+ )
+ # assert that response is a redirect to reverse("members")
+ self.assertRedirects(response, reverse("members"))
+ # assert that messages contains message, "Could not send email invitation"
+ mock_warning.assert_called_once_with(
+ response.wsgi_request, "Could not send email invitation."
+ )
+ # assert that portfolio invitation is not created
+ self.assertFalse(
+ PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
+ "Portfolio invitation should not be created when an EmailSendingError occurs."
+ )
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ @patch("registrar.views.portfolios.send_portfolio_invitation_email")
+ def test_submit_new_member_raises_missing_email_error(self, mock_send_email):
+ """Test when adding a new member and email_send method raises MissingEmailError."""
+ mock_send_email.side_effect = MissingEmailError(self.user.username)
+
+ self.client.force_login(self.user)
+
+ # Simulate a session to ensure continuity
+ session_id = self.client.session.session_key
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+ form_data = {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ "email": self.new_member_email,
+ }
+
+ # Act
+ with patch("django.contrib.messages.error") as mock_error:
+ response = self.client.post(reverse("new-member"), data=form_data)
+
+ # Assert
+ # assert that the send_portfolio_invitation_email called
+ mock_send_email.assert_called_once_with(
+ email=self.new_member_email, requestor=self.user, portfolio=self.portfolio
+ )
+ # assert that response is a redirect to reverse("members")
+ self.assertRedirects(response, reverse("members"))
+ # assert that messages contains message, "Could not send email invitation"
+ mock_error.assert_called_once_with(
+ response.wsgi_request, "Can't send invitation email. No email is associated with the account for 'test_user'."
+ )
+ # assert that portfolio invitation is not created
+ self.assertFalse(
+ PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
+ "Portfolio invitation should not be created when a MissingEmailError occurs."
+ )
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ @patch("registrar.views.portfolios.send_portfolio_invitation_email")
+ def test_submit_new_member_raises_exception(self, mock_send_email):
+ """Test when adding a new member and email_send method raises Exception."""
+ mock_send_email.side_effect = Exception("Generic exception")
+
+ self.client.force_login(self.user)
+
+ # Simulate a session to ensure continuity
+ session_id = self.client.session.session_key
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+ form_data = {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ "email": self.new_member_email,
+ }
+
+ # Act
+ with patch("django.contrib.messages.warning") as mock_warning:
+ response = self.client.post(reverse("new-member"), data=form_data)
+
+ # Assert
+ # assert that the send_portfolio_invitation_email called
+ mock_send_email.assert_called_once_with(
+ email=self.new_member_email, requestor=self.user, portfolio=self.portfolio
+ )
+ # assert that response is a redirect to reverse("members")
+ self.assertRedirects(response, reverse("members"))
+ # assert that messages contains message, "Could not send email invitation"
+ mock_warning.assert_called_once_with(
+ response.wsgi_request, "Could not send email invitation."
+ )
+ # assert that portfolio invitation is not created
+ self.assertFalse(
+ PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
+ "Portfolio invitation should not be created when an Exception occurs."
+ )
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ @patch("registrar.views.portfolios.send_portfolio_invitation_email")
+ def test_member_invite_for_previously_invited_member(self, mock_send_email):
"""Tests the member invitation flow for existing portfolio member."""
self.client.force_login(self.user)
@@ -2621,10 +2834,14 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)
+ # assert that send_portfolio_invitation_email is not called
+ mock_send_email.assert_not_called()
+
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
- def test_member_invite_for_existing_member(self):
+ @patch("registrar.views.portfolios.send_portfolio_invitation_email")
+ def test_member_invite_for_existing_member(self, mock_send_email):
"""Tests the member invitation flow for existing portfolio member."""
self.client.force_login(self.user)
@@ -2652,6 +2869,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)
+ # assert that send_portfolio_invitation_email is not called
+ mock_send_email.assert_not_called()
+
class TestEditPortfolioMemberView(WebTest):
"""Tests for the edit member page on portfolios"""
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index f5c16e2ac..34e98969f 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -594,5 +594,5 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
exc_info=True,
)
else:
- logger.warning("Could not send email invitation (Other Exception)", portfolio, exc_info=True)
+ logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.warning(self.request, "Could not send email invitation.")
From 8326bbbca5ea2a306fd9441b0b175149b41f709b Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Fri, 20 Dec 2024 20:08:18 -0500
Subject: [PATCH 045/114] tests on admin and forms
---
src/registrar/admin.py | 4 +-
src/registrar/tests/test_admin.py | 254 ++++++++++++++++++++++++++++++
src/registrar/tests/test_forms.py | 196 ++++++++++++++++++++++-
3 files changed, 451 insertions(+), 3 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 168c27c01..a617e29d9 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1492,7 +1492,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
Emails sent to requested user / email.
When exceptions are raised, return without saving model.
"""
- if not change: # Only send email if this is a new PortfolioInvitation(creation)
+ if not change: # Only send email if this is a new PortfolioInvitation (creation)
portfolio = obj.portfolio
requested_email = obj.email
requestor = request.user
@@ -1537,7 +1537,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
)
else:
- logger.warning("Could not send email invitation (Other Exception)", obj.portfolio, exc_info=True)
+ logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.error(request, "Could not send email invitation. Portfolio invitation not saved.")
def response_add(self, request, obj, post_url_continue=None):
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index a259e5bef..19d547c3a 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -2,6 +2,8 @@ from datetime import datetime
from django.utils import timezone
from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite
+from registrar.utility.email import EmailSendingError
+from registrar.utility.errors import MissingEmailError
from waffle.testutils import override_flag
from django_webtest import WebTest # type: ignore
from api.tests.common import less_console_noise_decorator
@@ -277,6 +279,29 @@ class TestUserPortfolioPermissionAdmin(TestCase):
# Should return the forbidden permissions for member role
self.assertEqual(member_only_permissions, set(member_forbidden))
+ @less_console_noise_decorator
+ def test_has_change_form_description(self):
+ """Tests if this model has a model description on the change form view"""
+ self.client.force_login(self.superuser)
+
+ user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
+ user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ )
+
+ response = self.client.get(
+ "/admin/registrar/userportfoliopermission/{}/change/".format(user_portfolio_permission.pk),
+ follow=True,
+ )
+
+ # Make sure that the page is loaded correctly
+ self.assertEqual(response.status_code, 200)
+
+ # Test for a description snippet
+ self.assertContains(
+ response,
+ "If you add someone to a portfolio here, it will not trigger an invitation email.",
+ )
+
class TestPortfolioInvitationAdmin(TestCase):
"""Tests for the PortfolioInvitationAdmin class as super user
@@ -432,6 +457,30 @@ class TestPortfolioInvitationAdmin(TestCase):
)
self.assertContains(response, "Show more")
+ @less_console_noise_decorator
+ def test_has_change_form_description(self):
+ """Tests if this model has a model description on the change form view"""
+ self.client.force_login(self.superuser)
+
+ invitation, _ = PortfolioInvitation.objects.get_or_create(
+ email=self.superuser.email, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ )
+
+ response = self.client.get(
+ "/admin/registrar/portfolioinvitation/{}/change/".format(invitation.pk),
+ follow=True,
+ )
+
+ # Make sure that the page is loaded correctly
+ self.assertEqual(response.status_code, 200)
+
+ # Test for a description snippet
+ self.assertContains(
+ response,
+ "If you add someone to a portfolio here, it will trigger an invitation email when you click",
+ )
+
+ @less_console_noise_decorator
def test_get_filters(self):
"""Ensures that our filters are displaying correctly"""
with less_console_noise():
@@ -456,6 +505,211 @@ class TestPortfolioInvitationAdmin(TestCase):
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
+ @less_console_noise_decorator
+ @patch("registrar.admin.send_portfolio_invitation_email")
+ @patch("django.contrib.messages.success") # Mock the `messages.warning` call
+ def test_save_sends_email(self, mock_messages_warning, mock_send_email):
+ """On save_model, an email is NOT sent if an invitation already exists."""
+ self.client.force_login(self.superuser)
+
+ # Create an instance of the admin class
+ admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
+
+ # Create a PortfolioInvitation instance
+ portfolio_invitation = PortfolioInvitation(
+ email="james.gordon@gotham.gov",
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
+ # Create a request object
+ request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
+ request.user = self.superuser
+
+ # Call the save_model method
+ admin_instance.save_model(request, portfolio_invitation, None, None)
+
+ # Assert that send_portfolio_invitation_email is not called
+ mock_send_email.assert_called()
+
+ # Get the arguments passed to send_portfolio_invitation_email
+ _, called_kwargs = mock_send_email.call_args
+
+ # Assert the email content
+ self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov")
+ self.assertEqual(called_kwargs["requestor"], self.superuser)
+ self.assertEqual(called_kwargs["portfolio"], self.portfolio)
+
+ # Assert that a warning message was triggered
+ mock_messages_warning.assert_called_once_with(request, "james.gordon@gotham.gov has been invited.")
+
+ @less_console_noise_decorator
+ @patch("registrar.admin.send_portfolio_invitation_email")
+ @patch("django.contrib.messages.warning") # Mock the `messages.warning` call
+ def test_save_does_not_send_email_if_requested_user_exists(self, mock_messages_warning, mock_send_email):
+ """On save_model, an email is NOT sent if an the requested email belongs to an existing user.
+ It also throws a warning."""
+ self.client.force_login(self.superuser)
+
+ # Create an instance of the admin class
+ admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
+
+ # Mock the UserPortfolioPermission query to simulate the invitation already existing
+ existing_user = create_user()
+ UserPortfolioPermission.objects.create(user=existing_user, portfolio=self.portfolio)
+
+ # Create a PortfolioInvitation instance
+ portfolio_invitation = PortfolioInvitation(
+ email=existing_user.email,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
+ # Create a request object
+ request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
+ request.user = self.superuser
+
+ # Call the save_model method
+ admin_instance.save_model(request, portfolio_invitation, None, None)
+
+ # Assert that send_portfolio_invitation_email is not called
+ mock_send_email.assert_not_called()
+
+ # Assert that a warning message was triggered
+ mock_messages_warning.assert_called_once_with(request, "User is already a member of this portfolio.")
+
+ @less_console_noise_decorator
+ @patch("registrar.admin.send_portfolio_invitation_email")
+ @patch("django.contrib.messages.error") # Mock the `messages.error` call
+ def test_save_exception_email_sending_error(self, mock_messages_error, mock_send_email):
+ """Handle EmailSendingError correctly when sending the portfolio invitation fails."""
+ self.client.force_login(self.superuser)
+
+ # Mock the email sending function to raise EmailSendingError
+ mock_send_email.side_effect = EmailSendingError("Email service unavailable")
+
+ # Create an instance of the admin class
+ admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
+
+ # Create a PortfolioInvitation instance
+ portfolio_invitation = PortfolioInvitation(
+ email="james.gordon@gotham.gov",
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
+ # Create a request object
+ request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
+ request.user = self.superuser
+
+ # Call the save_model method
+ admin_instance.save_model(request, portfolio_invitation, None, None)
+
+ # Assert that messages.error was called with the correct message
+ mock_messages_error.assert_called_once_with(
+ request, "Could not send email invitation. Portfolio invitation not saved."
+ )
+
+ @less_console_noise_decorator
+ @patch("registrar.admin.send_portfolio_invitation_email")
+ @patch("django.contrib.messages.error") # Mock the `messages.error` call
+ def test_save_exception_missing_email_error(self, mock_messages_error, mock_send_email):
+ """Handle MissingEmailError correctly when no email exists for the requestor."""
+ self.client.force_login(self.superuser)
+
+ # Mock the email sending function to raise MissingEmailError
+ mock_send_email.side_effect = MissingEmailError("Email-less Rachid")
+
+ # Create an instance of the admin class
+ admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
+
+ # Create a PortfolioInvitation instance
+ portfolio_invitation = PortfolioInvitation(
+ email="james.gordon@gotham.gov",
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
+ # Create a request object
+ request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
+ request.user = self.superuser
+
+ # Call the save_model method
+ admin_instance.save_model(request, portfolio_invitation, None, None)
+
+ # 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 the account for 'Email-less Rachid'.",
+ )
+
+ @less_console_noise_decorator
+ @patch("registrar.admin.send_portfolio_invitation_email")
+ @patch("django.contrib.messages.error") # Mock the `messages.error` call
+ def test_save_exception_generic_error(self, mock_messages_error, mock_send_email):
+ """Handle generic exceptions correctly during portfolio invitation."""
+ self.client.force_login(self.superuser)
+
+ # Mock the email sending function to raise a generic exception
+ mock_send_email.side_effect = Exception("Unexpected error")
+
+ # Create an instance of the admin class
+ admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
+
+ # Create a PortfolioInvitation instance
+ portfolio_invitation = PortfolioInvitation(
+ email="james.gordon@gotham.gov",
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
+ # Create a request object
+ request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
+ request.user = self.superuser
+
+ # Call the save_model method
+ admin_instance.save_model(request, portfolio_invitation, None, None)
+
+ # Assert that messages.error was called with the correct message
+ mock_messages_error.assert_called_once_with(
+ request, "Could not send email invitation. Portfolio invitation not saved."
+ )
+
+ # @patch("django.contrib.messages.get_messages")
+ # def test_form_rerenders_if_errors_or_warnings(self, mock_get_messages):
+ # """Ensure the form is re-rendered when errors or warnings are present."""
+ # self.client.force_login(self.superuser)
+
+ # # Mock the presence of an error message
+ # mock_message = Mock()
+ # mock_message.level_tag = "error"
+ # mock_message.message = "Simulated error message"
+ # mock_get_messages.return_value = [mock_message]
+
+ # # Create an instance of the admin class
+ # admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=AdminSite())
+
+ # # Create a PortfolioInvitation instance
+ # portfolio_invitation = PortfolioInvitation(
+ # email="james.gordon@gotham.gov",
+ # portfolio=self.portfolio,
+ # roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ # )
+
+ # # Create a request object
+ # request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
+ # request.user = self.superuser
+
+ # # Trigger response_add
+ # response = admin_instance.response_add(request, portfolio_invitation)
+
+ # # Assert that the response status code is 200 (indicating the form was re-rendered)
+ # self.assertEqual(response.status_code, 200, msg="Expected form to re-render due to errors.")
+
+ # # Assert that the mocked error message is included in the response
+ # self.assertContains(response, "Simulated error message", msg_prefix="Expected error message not found.")
+
+
class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user
diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py
index 12d9af8ac..bf4fba623 100644
--- a/src/registrar/tests/test_forms.py
+++ b/src/registrar/tests/test_forms.py
@@ -18,7 +18,13 @@ from registrar.forms.domain_request_wizard import (
AboutYourOrganizationForm,
)
from registrar.forms.domain import ContactForm
-from registrar.tests.common import MockEppLib
+from registrar.forms.portfolio import BasePortfolioMemberForm, PortfolioInvitedMemberForm, PortfolioMemberForm, PortfolioNewMemberForm
+from registrar.models.portfolio import Portfolio
+from registrar.models.portfolio_invitation import PortfolioInvitation
+from registrar.models.user import User
+from registrar.models.user_portfolio_permission import UserPortfolioPermission
+from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
+from registrar.tests.common import MockEppLib, create_user
from django.contrib.auth import get_user_model
@@ -408,3 +414,191 @@ class TestContactForm(TestCase):
def test_contact_form_email_invalid2(self):
form = ContactForm(data={"email": "@"})
self.assertEqual(form.errors["email"], ["Enter a valid email address."])
+
+
+class TestBasePortfolioMemberForms(TestCase):
+ """We test on the child forms instead of BasePortfolioMemberForm becasue the base form
+ is a model form with no model bound."""
+
+ def setUp(self):
+ super().setUp()
+ self.user = create_user()
+ self.portfolio, _ = Portfolio.objects.get_or_create(creator_id=self.user.id, organization_name="Hotel California")
+
+ def tearDown(self):
+ super().tearDown()
+ Portfolio.objects.all().delete()
+ UserPortfolioPermission.objects.all().delete()
+ PortfolioInvitation.objects.all().delete()
+ User.objects.all().delete()
+
+ def _assert_form_is_valid(self, form_class, data, instance=None):
+ if instance != None:
+ form = form_class(data=data, instance=instance)
+ else:
+ print('no instance')
+ form = form_class(data=data)
+ self.assertTrue(form.is_valid(), f"Form {form_class.__name__} failed validation with data: {data}")
+ return form
+
+ def _assert_form_has_error(self, form_class, data, field_name):
+ form = form_class(data=data)
+ self.assertFalse(form.is_valid())
+ self.assertIn(field_name, form.errors)
+
+ def _assert_initial_data(self, form_class, instance, expected_initial_data):
+ """Helper to check if the instance data is correctly mapped to the initial form values."""
+ form = form_class(instance=instance)
+ for field, expected_value in expected_initial_data.items():
+ self.assertEqual(form.initial[field], expected_value)
+
+ def _assert_permission_mapping(self, form_class, data, expected_permissions):
+ """Helper to check if permissions are correctly handled and mapped."""
+ form = self._assert_form_is_valid(form_class, data)
+ cleaned_data = form.cleaned_data
+ for permission in expected_permissions:
+ self.assertIn(permission, cleaned_data["additional_permissions"])
+
+ def test_required_field_for_admin(self):
+ """Test that required fields are validated for an admin role."""
+ data = {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
+ "domain_request_permission_admin": "", # Simulate missing field
+ "member_permission_admin": "", # Simulate missing field
+ }
+
+ # Check required fields for all forms
+ self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin")
+ self._assert_form_has_error(PortfolioMemberForm, data, "member_permission_admin")
+
+ self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_admin")
+ self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin")
+
+ self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_admin")
+ self._assert_form_has_error(PortfolioNewMemberForm, data, "member_permission_admin")
+
+ def test_required_field_for_member(self):
+ """Test that required fields are validated for a member role."""
+ data = {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": "", # Simulate missing field
+ }
+
+ # Check required fields for all forms
+ self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_member")
+ self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_member")
+ self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_member")
+
+ def test_clean_validates_required_fields_for_role(self):
+ """Test that the `clean` method validates the correct fields for each role.
+
+ For PortfolioMemberForm and PortfolioInvitedMemberForm, we pass an object as the instance to the form.
+ For UserPortfolioPermissionChoices, we add a portfolio and an email to the POST data.
+
+ These things are handled in the views."""
+
+ user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=self.portfolio, user=self.user)
+ portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho")
+
+ data = {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
+ "domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value,
+ }
+
+ # Check form validity for all forms
+ form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission)
+ cleaned_data = form.cleaned_data
+ self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
+ self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
+
+ form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation)
+ cleaned_data = form.cleaned_data
+ self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
+ self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
+
+ data = {
+ "email": "hi@ho.com",
+ "portfolio": self.portfolio.id,
+ "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
+ "domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value,
+ }
+
+ form = self._assert_form_is_valid(PortfolioNewMemberForm, data)
+ cleaned_data = form.cleaned_data
+ self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
+ self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
+
+ def test_clean_member_permission_edgecase(self):
+ """Test that the clean method correctly handles the special "no_access" value for members.
+ We'll need to add a portfolio, which in the app is handled by the view post."""
+
+ user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=self.portfolio, user=self.user)
+ portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho")
+
+ data = {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permission_member": "no_access", # Simulate no access permission
+ }
+
+ form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission)
+ cleaned_data = form.cleaned_data
+ self.assertEqual(cleaned_data["domain_request_permission_member"], None)
+
+ form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation)
+ cleaned_data = form.cleaned_data
+ self.assertEqual(cleaned_data["domain_request_permission_member"], None)
+
+
+ def test_map_instance_to_initial_admin_role(self):
+ """Test that instance data is correctly mapped to the initial form values for an admin role."""
+ user_portfolio_permission = UserPortfolioPermission(
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
+ )
+ portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
+ portfolio=self.portfolio,
+ email="hi@ho",
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
+ )
+
+ expected_initial_data = {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
+ "domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
+ "member_permission_admin": UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ }
+ self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data)
+ self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data)
+
+ def test_map_instance_to_initial_member_role(self):
+ """Test that instance data is correctly mapped to the initial form values for a member role."""
+ user_portfolio_permission = UserPortfolioPermission(
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
+ )
+ portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
+ portfolio=self.portfolio,
+ email="hi@ho",
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
+ )
+ expected_initial_data = {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
+ "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
+ }
+ self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data)
+ self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data)
+
+ def test_invalid_data_for_admin(self):
+ """Test invalid form submission for an admin role with missing permissions."""
+ data = {
+ "email": "hi@ho.com",
+ "portfolio": self.portfolio.id,
+ "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
+ "domain_request_permission_admin": "", # Missing field
+ "member_permission_admin": "", # Missing field
+ }
+ self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin")
+ self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin")
From d36e2e2a63b8cf7ee99f32690d82acb7d3c07b1f Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 20 Dec 2024 20:29:21 -0500
Subject: [PATCH 046/114] domain manager tests and linted
---
src/registrar/tests/test_admin.py | 1 -
src/registrar/tests/test_forms.py | 35 +++--
src/registrar/tests/test_views_domain.py | 151 +++++++++++++++++++-
src/registrar/tests/test_views_portfolio.py | 76 +++++-----
src/registrar/views/domain.py | 6 +-
5 files changed, 218 insertions(+), 51 deletions(-)
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 19d547c3a..9e4644e62 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -710,7 +710,6 @@ class TestPortfolioInvitationAdmin(TestCase):
# self.assertContains(response, "Simulated error message", msg_prefix="Expected error message not found.")
-
class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user
diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py
index bf4fba623..47b0f3158 100644
--- a/src/registrar/tests/test_forms.py
+++ b/src/registrar/tests/test_forms.py
@@ -18,7 +18,11 @@ from registrar.forms.domain_request_wizard import (
AboutYourOrganizationForm,
)
from registrar.forms.domain import ContactForm
-from registrar.forms.portfolio import BasePortfolioMemberForm, PortfolioInvitedMemberForm, PortfolioMemberForm, PortfolioNewMemberForm
+from registrar.forms.portfolio import (
+ PortfolioInvitedMemberForm,
+ PortfolioMemberForm,
+ PortfolioNewMemberForm,
+)
from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user import User
@@ -423,7 +427,9 @@ class TestBasePortfolioMemberForms(TestCase):
def setUp(self):
super().setUp()
self.user = create_user()
- self.portfolio, _ = Portfolio.objects.get_or_create(creator_id=self.user.id, organization_name="Hotel California")
+ self.portfolio, _ = Portfolio.objects.get_or_create(
+ creator_id=self.user.id, organization_name="Hotel California"
+ )
def tearDown(self):
super().tearDown()
@@ -433,10 +439,10 @@ class TestBasePortfolioMemberForms(TestCase):
User.objects.all().delete()
def _assert_form_is_valid(self, form_class, data, instance=None):
- if instance != None:
+ if instance is not None:
form = form_class(data=data, instance=instance)
else:
- print('no instance')
+ print("no instance")
form = form_class(data=data)
self.assertTrue(form.is_valid(), f"Form {form_class.__name__} failed validation with data: {data}")
return form
@@ -491,13 +497,15 @@ class TestBasePortfolioMemberForms(TestCase):
def test_clean_validates_required_fields_for_role(self):
"""Test that the `clean` method validates the correct fields for each role.
-
+
For PortfolioMemberForm and PortfolioInvitedMemberForm, we pass an object as the instance to the form.
For UserPortfolioPermissionChoices, we add a portfolio and an email to the POST data.
-
+
These things are handled in the views."""
- user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=self.portfolio, user=self.user)
+ user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
+ portfolio=self.portfolio, user=self.user
+ )
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho")
data = {
@@ -519,7 +527,7 @@ class TestBasePortfolioMemberForms(TestCase):
data = {
"email": "hi@ho.com",
- "portfolio": self.portfolio.id,
+ "portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value,
@@ -534,7 +542,9 @@ class TestBasePortfolioMemberForms(TestCase):
"""Test that the clean method correctly handles the special "no_access" value for members.
We'll need to add a portfolio, which in the app is handled by the view post."""
- user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=self.portfolio, user=self.user)
+ user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
+ portfolio=self.portfolio, user=self.user
+ )
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho")
data = {
@@ -550,7 +560,6 @@ class TestBasePortfolioMemberForms(TestCase):
cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["domain_request_permission_member"], None)
-
def test_map_instance_to_initial_admin_role(self):
"""Test that instance data is correctly mapped to the initial form values for an admin role."""
user_portfolio_permission = UserPortfolioPermission(
@@ -558,7 +567,7 @@ class TestBasePortfolioMemberForms(TestCase):
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
- portfolio=self.portfolio,
+ portfolio=self.portfolio,
email="hi@ho",
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
@@ -579,7 +588,7 @@ class TestBasePortfolioMemberForms(TestCase):
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
- portfolio=self.portfolio,
+ portfolio=self.portfolio,
email="hi@ho",
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
@@ -595,7 +604,7 @@ class TestBasePortfolioMemberForms(TestCase):
"""Test invalid form submission for an admin role with missing permissions."""
data = {
"email": "hi@ho.com",
- "portfolio": self.portfolio.id,
+ "portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
"domain_request_permission_admin": "", # Missing field
"member_permission_admin": "", # Missing field
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 3f1e85dfa..eae5f6dc5 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -4,6 +4,8 @@ from unittest.mock import MagicMock, ANY, patch
from django.conf import settings
from django.urls import reverse
from django.contrib.auth import get_user_model
+from registrar.models.portfolio_invitation import PortfolioInvitation
+from registrar.utility.email import EmailSendingError
from waffle.testutils import override_flag
from api.tests.common import less_console_noise_decorator
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@@ -454,6 +456,7 @@ class TestDomainManagers(TestDomainOverview):
def tearDown(self):
"""Ensure that the user has its original permissions"""
+ PortfolioInvitation.objects.all().delete()
super().tearDown()
@less_console_noise_decorator
@@ -486,7 +489,7 @@ class TestDomainManagers(TestDomainOverview):
@less_console_noise_decorator
def test_domain_user_add_form(self):
"""Adding an existing user works."""
- other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov")
+ get_user_model().objects.get_or_create(email="mayor@igorville.gov")
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
@@ -509,6 +512,148 @@ class TestDomainManagers(TestDomainOverview):
success_page = success_result.follow()
self.assertContains(success_page, "mayor@igorville.gov")
+ @boto3_mocking.patching
+ @override_flag("organization_feature", active=True)
+ @less_console_noise_decorator
+ @patch("registrar.views.domain.send_portfolio_invitation_email")
+ @patch("registrar.views.domain.send_domain_invitation_email")
+ def test_domain_user_add_form_sends_portfolio_invitation(self, mock_send_domain_email, mock_send_portfolio_email):
+ """Adding an existing user works and sends portfolio invitation when
+ user is not member of portfolio."""
+ get_user_model().objects.get_or_create(email="mayor@igorville.gov")
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+
+ add_page.form["email"] = "mayor@igorville.gov"
+
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+ success_result = add_page.form.submit()
+
+ self.assertEqual(success_result.status_code, 302)
+ self.assertEqual(
+ success_result["Location"],
+ reverse("domain-users", kwargs={"pk": self.domain.id}),
+ )
+
+ # Verify that the invitation emails were sent
+ mock_send_portfolio_email.assert_called_once_with(
+ email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
+ )
+ mock_send_domain_email.assert_called_once()
+ call_args = mock_send_domain_email.call_args.kwargs
+ self.assertEqual(call_args["email"], "mayor@igorville.gov")
+ self.assertEqual(call_args["requestor"], self.user)
+ self.assertEqual(call_args["domain"], self.domain)
+ self.assertIsNone(call_args.get("is_member_of_different_org"))
+
+ # Assert that the PortfolioInvitation is created
+ portfolio_invitation = PortfolioInvitation.objects.filter(
+ email="mayor@igorville.gov", portfolio=self.portfolio
+ ).first()
+ self.assertIsNotNone(portfolio_invitation, "Portfolio invitation should be created.")
+ self.assertEqual(portfolio_invitation.email, "mayor@igorville.gov")
+ self.assertEqual(portfolio_invitation.portfolio, self.portfolio)
+
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ success_page = success_result.follow()
+ self.assertContains(success_page, "mayor@igorville.gov")
+
+ @boto3_mocking.patching
+ @override_flag("organization_feature", active=True)
+ @less_console_noise_decorator
+ @patch("registrar.views.domain.send_portfolio_invitation_email")
+ @patch("registrar.views.domain.send_domain_invitation_email")
+ def test_domain_user_add_form_doesnt_send_portfolio_invitation_if_already_member(
+ self, mock_send_domain_email, mock_send_portfolio_email
+ ):
+ """Adding an existing user works and sends portfolio invitation when
+ user is not member of portfolio."""
+ other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov")
+ UserPortfolioPermission.objects.get_or_create(
+ user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ )
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+
+ add_page.form["email"] = "mayor@igorville.gov"
+
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+ success_result = add_page.form.submit()
+
+ self.assertEqual(success_result.status_code, 302)
+ self.assertEqual(
+ success_result["Location"],
+ reverse("domain-users", kwargs={"pk": self.domain.id}),
+ )
+
+ # Verify that the invitation emails were sent
+ mock_send_portfolio_email.assert_not_called()
+ mock_send_domain_email.assert_called_once()
+ call_args = mock_send_domain_email.call_args.kwargs
+ self.assertEqual(call_args["email"], "mayor@igorville.gov")
+ self.assertEqual(call_args["requestor"], self.user)
+ self.assertEqual(call_args["domain"], self.domain)
+ self.assertIsNone(call_args.get("is_member_of_different_org"))
+
+ # Assert that no PortfolioInvitation is created
+ portfolio_invitation_exists = PortfolioInvitation.objects.filter(
+ email="mayor@igorville.gov", portfolio=self.portfolio
+ ).exists()
+ self.assertFalse(
+ portfolio_invitation_exists, "Portfolio invitation should not be created when the user is already a member."
+ )
+
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ success_page = success_result.follow()
+ self.assertContains(success_page, "mayor@igorville.gov")
+
+ @boto3_mocking.patching
+ @override_flag("organization_feature", active=True)
+ @less_console_noise_decorator
+ @patch("registrar.views.domain.send_portfolio_invitation_email")
+ @patch("registrar.views.domain.send_domain_invitation_email")
+ def test_domain_user_add_form_sends_portfolio_invitation_raises_email_sending_error(
+ self, mock_send_domain_email, mock_send_portfolio_email
+ ):
+ """Adding an existing user works and attempts to send portfolio invitation when
+ user is not member of portfolio and send raises an error."""
+ mock_send_portfolio_email.side_effect = EmailSendingError("Failed to send email.")
+ get_user_model().objects.get_or_create(email="mayor@igorville.gov")
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+
+ add_page.form["email"] = "mayor@igorville.gov"
+
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+ success_result = add_page.form.submit()
+
+ self.assertEqual(success_result.status_code, 302)
+ self.assertEqual(
+ success_result["Location"],
+ reverse("domain-users", kwargs={"pk": self.domain.id}),
+ )
+
+ # Verify that the invitation emails were sent
+ mock_send_portfolio_email.assert_called_once_with(
+ email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
+ )
+ mock_send_domain_email.assert_not_called()
+
+ # Assert that no PortfolioInvitation is created
+ portfolio_invitation_exists = PortfolioInvitation.objects.filter(
+ email="mayor@igorville.gov", portfolio=self.portfolio
+ ).exists()
+ self.assertFalse(
+ portfolio_invitation_exists, "Portfolio invitation should not be created when email fails to send."
+ )
+
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ success_page = success_result.follow()
+ self.assertContains(success_page, "Could not send email invitation.")
+
@boto3_mocking.patching
@less_console_noise_decorator
def test_domain_invitation_created(self):
@@ -757,7 +902,9 @@ class TestDomainManagers(TestDomainOverview):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
- expected_message_content = f"Can't send invitation email. No email is associated with the account for 'test_user'."
+ expected_message_content = (
+ "Can't send invitation email. No email is associated with the account for 'test_user'."
+ )
# 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 d1c533489..807fc8214 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -20,7 +20,6 @@ from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.tests.test_views import TestWithUser
from registrar.utility.email import EmailSendingError
-from registrar.utility.email_invitations import send_portfolio_invitation_email
from registrar.utility.errors import MissingEmailError
from .common import MockSESClient, completed_domain_request, create_test_user, create_user
from waffle.testutils import override_flag
@@ -2585,7 +2584,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
)
# Ensure the final submission is successful
- self.assertEqual(final_response.status_code, 302) # Redirects
+ self.assertEqual(final_response.status_code, 302) # Redirects
# Validate Database Changes
portfolio_invite = PortfolioInvitation.objects.filter(
@@ -2611,7 +2610,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
mock_client_class = MagicMock()
mock_client = mock_client_class.return_value
-
+
with boto3_mocking.clients.handler_for("sesv2", mock_client_class):
# Simulate submission of member invite for new user
final_response = self.client.post(
@@ -2635,7 +2634,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# assert that portfolio invitation is not created
self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
- "Portfolio invitation should not be created when an Exception occurs."
+ "Portfolio invitation should not be created when an Exception occurs.",
)
# Check that an email was not sent
@@ -2671,7 +2670,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
json_response = response.json()
self.assertIn("is_valid", json_response)
self.assertFalse(json_response["is_valid"])
-
+
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)
@@ -2686,9 +2685,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
def test_submit_new_member_raises_email_sending_error(self, mock_send_email):
"""Test when adding a new member and email_send method raises EmailSendingError."""
mock_send_email.side_effect = EmailSendingError("Failed to send email.")
-
+
self.client.force_login(self.user)
-
+
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@@ -2698,11 +2697,11 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.new_member_email,
}
-
+
# Act
with patch("django.contrib.messages.warning") as mock_warning:
response = self.client.post(reverse("new-member"), data=form_data)
-
+
# Assert
# assert that the send_portfolio_invitation_email called
mock_send_email.assert_called_once_with(
@@ -2711,13 +2710,11 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# assert that response is a redirect to reverse("members")
self.assertRedirects(response, reverse("members"))
# assert that messages contains message, "Could not send email invitation"
- mock_warning.assert_called_once_with(
- response.wsgi_request, "Could not send email invitation."
- )
+ mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.")
# assert that portfolio invitation is not created
self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
- "Portfolio invitation should not be created when an EmailSendingError occurs."
+ "Portfolio invitation should not be created when an EmailSendingError occurs.",
)
@less_console_noise_decorator
@@ -2727,9 +2724,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
def test_submit_new_member_raises_missing_email_error(self, mock_send_email):
"""Test when adding a new member and email_send method raises MissingEmailError."""
mock_send_email.side_effect = MissingEmailError(self.user.username)
-
+
self.client.force_login(self.user)
-
+
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@@ -2739,11 +2736,11 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.new_member_email,
}
-
+
# Act
with patch("django.contrib.messages.error") as mock_error:
response = self.client.post(reverse("new-member"), data=form_data)
-
+
# Assert
# assert that the send_portfolio_invitation_email called
mock_send_email.assert_called_once_with(
@@ -2753,12 +2750,13 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
self.assertRedirects(response, reverse("members"))
# assert that messages contains message, "Could not send email invitation"
mock_error.assert_called_once_with(
- response.wsgi_request, "Can't send invitation email. No email is associated with the account for 'test_user'."
+ response.wsgi_request,
+ "Can't send invitation email. No email is associated with the account for 'test_user'.",
)
# assert that portfolio invitation is not created
self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
- "Portfolio invitation should not be created when a MissingEmailError occurs."
+ "Portfolio invitation should not be created when a MissingEmailError occurs.",
)
@less_console_noise_decorator
@@ -2768,9 +2766,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
def test_submit_new_member_raises_exception(self, mock_send_email):
"""Test when adding a new member and email_send method raises Exception."""
mock_send_email.side_effect = Exception("Generic exception")
-
+
self.client.force_login(self.user)
-
+
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@@ -2780,11 +2778,11 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.new_member_email,
}
-
+
# Act
with patch("django.contrib.messages.warning") as mock_warning:
response = self.client.post(reverse("new-member"), data=form_data)
-
+
# Assert
# assert that the send_portfolio_invitation_email called
mock_send_email.assert_called_once_with(
@@ -2793,14 +2791,12 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# assert that response is a redirect to reverse("members")
self.assertRedirects(response, reverse("members"))
# assert that messages contains message, "Could not send email invitation"
- mock_warning.assert_called_once_with(
- response.wsgi_request, "Could not send email invitation."
- )
+ mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.")
# assert that portfolio invitation is not created
self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
- "Portfolio invitation should not be created when an Exception occurs."
- )
+ "Portfolio invitation should not be created when an Exception occurs.",
+ )
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@@ -2828,7 +2824,14 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
self.assertEqual(response.status_code, 200)
# verify messages
- self.assertContains(response, "This user is already assigned to a portfolio invitation. Based on current waffle flag settings, users cannot be assigned to multiple portfolios.")
+ self.assertContains(
+ response,
+ (
+ "This user is already assigned to a portfolio invitation. "
+ "Based on current waffle flag settings, users cannot be assigned "
+ "to multiple portfolios."
+ ),
+ )
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
@@ -2863,7 +2866,14 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
self.assertEqual(response.status_code, 200)
# Verify messages
- self.assertContains(response, "This user is already assigned to a portfolio. Based on current waffle flag settings, users cannot be assigned to multiple portfolios.")
+ self.assertContains(
+ response,
+ (
+ "This user is already assigned to a portfolio. "
+ "Based on current waffle flag settings, users cannot be "
+ "assigned to multiple portfolios."
+ ),
+ )
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
@@ -3013,9 +3023,11 @@ class TestEditPortfolioMemberView(WebTest):
@override_flag("organization_members", active=True)
def test_admin_removing_own_admin_role(self):
"""Tests an admin removing their own admin role redirects to home.
-
+
Removing the admin role will remove both view and edit members permissions.
- Note: The user can remove the edit members permissions but as long as they stay in admin role, they will at least still have view members permissions."""
+ Note: The user can remove the edit members permissions but as long as they
+ stay in admin role, they will at least still have view members permissions.
+ """
self.client.force_login(self.user)
diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py
index f17acb820..0ce4d7d51 100644
--- a/src/registrar/views/domain.py
+++ b/src/registrar/views/domain.py
@@ -1333,13 +1333,13 @@ class DomainAddUserView(DomainFormBaseView):
elif isinstance(exception, IntegrityError):
messages.warning(self.request, f"{email} is already a manager for this domain")
else:
- logger.warning("Could not send email invitation (Other Exception)", self.object, exc_info=True)
+ logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.warning(self.request, "Could not send email invitation.")
def _handle_portfolio_exceptions(self, exception, email, portfolio):
"""Handle exceptions raised during the process."""
if isinstance(exception, EmailSendingError):
- logger.warning("Could not send email invitation (EmailSendingError)", portfolio, exc_info=True)
+ logger.warning("Could not send email invitation (EmailSendingError)", exc_info=True)
messages.warning(self.request, "Could not send email invitation.")
elif isinstance(exception, MissingEmailError):
messages.error(self.request, str(exception))
@@ -1348,7 +1348,7 @@ class DomainAddUserView(DomainFormBaseView):
exc_info=True,
)
else:
- logger.warning("Could not send email invitation (Other Exception)", portfolio, exc_info=True)
+ logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.warning(self.request, "Could not send email invitation.")
From f277efc6c43adf072ebe26091c50e66ffbcaf8c3 Mon Sep 17 00:00:00 2001
From: Matt-Spence
Date: Mon, 23 Dec 2024 14:57:37 -0600
Subject: [PATCH 047/114] Update src/registrar/admin.py
Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
---
src/registrar/admin.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index c04975cb9..117f689f8 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -2442,10 +2442,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
obj_id = domain.id
change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id])
- message = f"The status of this domain request cannot be changed because it has been joined to a domain in Ready status: " # noqa
- message += f"{domain}"
-
- message_html = mark_safe(message) # nosec
+message = format_html(
+ "The status of this domain request cannot be changed because it has been joined to a domain in Ready status:"
+ "{}",
+ mark_safe(change_url),
+ escape(str(domain))
+)
messages.warning(
request,
message_html,
From ee2bff6492d87ccccc0e1ddbb1f0948f7a885108 Mon Sep 17 00:00:00 2001
From: Matthew Spence
Date: Mon, 23 Dec 2024 15:31:04 -0600
Subject: [PATCH 048/114] fix warning html
---
src/registrar/admin.py | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 117f689f8..c5aae7d2d 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -2442,16 +2442,16 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
obj_id = domain.id
change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id])
-message = format_html(
- "The status of this domain request cannot be changed because it has been joined to a domain in Ready status:"
- "{}",
- mark_safe(change_url),
- escape(str(domain))
-)
- messages.warning(
- request,
- message_html,
- )
+ message = format_html(
+ "The status of this domain request cannot be changed because it has been joined to a domain in Ready status:"
+ "{}",
+ mark_safe(change_url),
+ escape(str(domain))
+ )
+ messages.warning(
+ request,
+ message,
+ )
obj = self.get_object(request, object_id)
self.display_restricted_warning(request, obj)
From c0684539fa74b334de02a5e77b75968de0eded99 Mon Sep 17 00:00:00 2001
From: CocoByte
Date: Wed, 25 Dec 2024 20:35:18 -0700
Subject: [PATCH 049/114] fix bug in domain request link
---
src/registrar/admin.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index f43261885..874512fdc 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1995,7 +1995,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
@admin.display(description=_("Requested Domain"))
def custom_requested_domain(self, obj):
# Example: Show different icons based on `status`
- url = reverse("admin:registrar_domainrequest_changelist") + f"?portfolio={obj.id}"
+ url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}"
text = obj.requested_domain
if obj.portfolio:
return format_html('
{}', url, text)
From e978297569cedc34e7fe95ddbb261a7cfbd21795 Mon Sep 17 00:00:00 2001
From: CocoByte
Date: Thu, 26 Dec 2024 15:05:38 -0700
Subject: [PATCH 050/114] Fix suborg dropdown behavior
---
.../getgov-admin/helpers-portfolio-dynamic-fields.js | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js
index ce5db34c1..f045fca47 100644
--- a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js
+++ b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js
@@ -482,14 +482,14 @@ export function handlePortfolioSelection(
}
// Initially show / hide the clear button only if there is data to clear
- let requestedSuborganizationField = document.getElementById("id_requested_suborganization");
- let suborganizationCity = document.getElementById("id_suborganization_city");
- let suborganizationStateTerritory = document.getElementById("id_suborganization_state_territory");
- if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory) {
+ let requestedSuborganizationFieldInput = document.getElementById("id_requested_suborganization");
+ let suborganizationCityInput = document.getElementById("id_suborganization_city");
+ let suborganizationStateTerritoryInput = document.getElementById("id_suborganization_state_territory");
+ if (!requestedSuborganizationFieldInput || !suborganizationCityInput || !suborganizationStateTerritoryInput) {
return;
}
- if (requestedSuborganizationField.value || suborganizationCity.value || suborganizationStateTerritory.value) {
+ if (requestedSuborganizationFieldInput.value || suborganizationCityInput.value || suborganizationStateTerritoryInput.value) {
showElement(rejectSuborganizationButtonFieldset);
}else {
hideElement(rejectSuborganizationButtonFieldset);
From b43ba2af6f5a267343b15d1a603dbbaaa48797f6 Mon Sep 17 00:00:00 2001
From: CocoByte
Date: Thu, 26 Dec 2024 17:08:46 -0700
Subject: [PATCH 051/114] Fixed Request Growth report according to ticket
recommendations
---
src/registrar/utility/csv_export.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py
index 66809777b..10175569e 100644
--- a/src/registrar/utility/csv_export.py
+++ b/src/registrar/utility/csv_export.py
@@ -1980,7 +1980,7 @@ class DomainRequestGrowth(DomainRequestExport):
"Domain request",
"Domain type",
"Federal type",
- "Submitted at",
+ "First submitted date",
]
@classmethod
@@ -2004,7 +2004,6 @@ class DomainRequestGrowth(DomainRequestExport):
start_date_formatted = format_start_date(start_date)
end_date_formatted = format_end_date(end_date)
return Q(
- status=DomainRequest.DomainRequestStatus.SUBMITTED,
last_submitted_date__lte=end_date_formatted,
last_submitted_date__gte=start_date_formatted,
)
From 88cca2dc84ea86a8efb0ba65b50848218a647159 Mon Sep 17 00:00:00 2001
From: CocoByte
Date: Thu, 26 Dec 2024 18:02:56 -0700
Subject: [PATCH 052/114] update fixtures to include first_submitted_date data
---
src/registrar/fixtures/fixtures_requests.py | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/registrar/fixtures/fixtures_requests.py b/src/registrar/fixtures/fixtures_requests.py
index 93167ec61..5df3fd0b6 100644
--- a/src/registrar/fixtures/fixtures_requests.py
+++ b/src/registrar/fixtures/fixtures_requests.py
@@ -1,4 +1,4 @@
-from datetime import timedelta
+from datetime import datetime, timedelta
from django.utils import timezone
import logging
import random
@@ -126,7 +126,21 @@ class DomainRequestFixture:
# TODO for a future ticket: Allow for more than just "federal" here
request.generic_org_type = request_dict["generic_org_type"] if "generic_org_type" in request_dict else "federal"
if request.status != "started":
- request.last_submitted_date = fake.date()
+ # Generate fake data for first_submitted_date and last_submitted_date
+ # First generate a random date set to be later than 2020 (or something)
+ # (if we just use fake.date() we might get years like 1970 or earlier)
+ earliest_date_allowed = datetime(2020, 1, 1).date()
+ end_date = datetime.today().date() # Today's date (latest allowed date)
+ days_range = (end_date - earliest_date_allowed).days
+ first_submitted_date = earliest_date_allowed + timedelta(days=random.randint(0, days_range))
+
+ # Generate a random positive offset to ensure last_submitted_date is later
+ offset_days = random.randint(1, 30) # Ensures at least 1 day difference
+ last_submitted_date = first_submitted_date + timedelta(days=offset_days)
+
+ # Convert back to strings before assigning
+ request.first_submitted_date = first_submitted_date.strftime("%Y-%m-%d")
+ request.last_submitted_date = last_submitted_date.strftime("%Y-%m-%d")
request.federal_type = (
request_dict["federal_type"]
if "federal_type" in request_dict
From 86e436171cfe68fbbddbd25f4807c6daba1e6858 Mon Sep 17 00:00:00 2001
From: CocoByte
Date: Thu, 26 Dec 2024 18:29:33 -0700
Subject: [PATCH 053/114] linted
---
src/registrar/fixtures/fixtures_requests.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/fixtures/fixtures_requests.py b/src/registrar/fixtures/fixtures_requests.py
index 5df3fd0b6..6ac52bd8b 100644
--- a/src/registrar/fixtures/fixtures_requests.py
+++ b/src/registrar/fixtures/fixtures_requests.py
@@ -129,7 +129,7 @@ class DomainRequestFixture:
# Generate fake data for first_submitted_date and last_submitted_date
# First generate a random date set to be later than 2020 (or something)
# (if we just use fake.date() we might get years like 1970 or earlier)
- earliest_date_allowed = datetime(2020, 1, 1).date()
+ earliest_date_allowed = datetime(2020, 1, 1).date()
end_date = datetime.today().date() # Today's date (latest allowed date)
days_range = (end_date - earliest_date_allowed).days
first_submitted_date = earliest_date_allowed + timedelta(days=random.randint(0, days_range))
From d7493cc20560dba99960f392b2ddb460006e5043 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Mon, 30 Dec 2024 10:56:04 -0500
Subject: [PATCH 054/114] friendlier error message
---
src/registrar/tests/test_admin.py | 4 ++--
src/registrar/tests/test_views_portfolio.py | 4 ++--
src/registrar/utility/email_invitations.py | 4 ++--
src/registrar/utility/errors.py | 5 ++---
4 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 9e4644e62..02f44c5cf 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -618,7 +618,7 @@ class TestPortfolioInvitationAdmin(TestCase):
self.client.force_login(self.superuser)
# Mock the email sending function to raise MissingEmailError
- mock_send_email.side_effect = MissingEmailError("Email-less Rachid")
+ mock_send_email.side_effect = MissingEmailError()
# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
@@ -640,7 +640,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 the account for 'Email-less Rachid'.",
+ "Can't send invitation email. No email is associated with your user account.",
)
@less_console_noise_decorator
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 807fc8214..190d33e29 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -2723,7 +2723,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
def test_submit_new_member_raises_missing_email_error(self, mock_send_email):
"""Test when adding a new member and email_send method raises MissingEmailError."""
- mock_send_email.side_effect = MissingEmailError(self.user.username)
+ mock_send_email.side_effect = MissingEmailError()
self.client.force_login(self.user)
@@ -2751,7 +2751,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# assert that messages contains message, "Could not send email invitation"
mock_error.assert_called_once_with(
response.wsgi_request,
- "Can't send invitation email. No email is associated with the account for 'test_user'.",
+ "Can't send invitation email. No email is associated with your user account.",
)
# assert that portfolio invitation is not created
self.assertFalse(
diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py
index a3b93f5d5..7171b8902 100644
--- a/src/registrar/utility/email_invitations.py
+++ b/src/registrar/utility/email_invitations.py
@@ -38,7 +38,7 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif
# Check if the requestor is staff and has an email
if not requestor.is_staff:
if not requestor.email or requestor.email.strip() == "":
- raise MissingEmailError(requestor.username)
+ raise MissingEmailError
else:
requestor_email = requestor.email
@@ -98,7 +98,7 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio):
# Check if the requestor is staff and has an email
if not requestor.is_staff:
if not requestor.email or requestor.email.strip() == "":
- raise MissingEmailError(requestor.username)
+ raise MissingEmailError
else:
requestor_email = requestor.email
diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py
index 210d13081..039fb3696 100644
--- a/src/registrar/utility/errors.py
+++ b/src/registrar/utility/errors.py
@@ -46,9 +46,8 @@ class AlreadyDomainInvitedError(InvitationError):
class MissingEmailError(InvitationError):
"""Raised when the requestor has no email associated with their account."""
- def __init__(self, username):
- super().__init__(f"Can't send invitation email. No email is associated with the account for '{username}'.")
- self.username = username
+ def __init__(self):
+ super().__init__("Can't send invitation email. No email is associated with your user account.")
class OutsideOrgMemberError(ValueError):
From 267364e5567881018de10896605310c6840f8c8d Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Mon, 30 Dec 2024 11:04:03 -0500
Subject: [PATCH 055/114] fixed a test
---
src/registrar/tests/test_views_domain.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 38cb46a8c..ef5d26fb9 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -1009,7 +1009,7 @@ class TestDomainManagers(TestDomainOverview):
add_page.form.submit()
expected_message_content = (
- "Can't send invitation email. No email is associated with the account for 'test_user'."
+ "Can't send invitation email. No email is associated with your user account."
)
# Assert that the error message was called with the correct argument
From e845fe1f77f30e4621d7b6247caac779749c4607 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Mon, 30 Dec 2024 11:05:45 -0500
Subject: [PATCH 056/114] linted
---
src/registrar/tests/test_views_domain.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index ef5d26fb9..3972f0555 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -1008,9 +1008,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(
From 887a9dfc3318a037b01520cbf474893b66548ab0 Mon Sep 17 00:00:00 2001
From: matthewswspence
Date: Mon, 30 Dec 2024 10:18:08 -0600
Subject: [PATCH 057/114] linter changes
---
src/registrar/admin.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 2b58cb5fc..bbebbdb80 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -2632,10 +2632,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id])
message = format_html(
- "The status of this domain request cannot be changed because it has been joined to a domain in Ready status:"
+ "The status of this domain request cannot be changed because it has been joined to a domain in Ready status:" # noqa: E501
"{}",
- mark_safe(change_url),
- escape(str(domain))
+ mark_safe(change_url), # nosec
+ escape(str(domain)),
)
messages.warning(
request,
From 0e3a1053346df22fd5e03b3c2e8358cea7c22aa3 Mon Sep 17 00:00:00 2001
From: Rachid Mrad <107004823+rachidatecs@users.noreply.github.com>
Date: Mon, 30 Dec 2024 11:52:38 -0500
Subject: [PATCH 058/114] Update
src/registrar/assets/src/sass/_theme/_typography.scss
Co-authored-by: Alysia Broddrick <109625347+abroddrick@users.noreply.github.com>
---
src/registrar/assets/src/sass/_theme/_typography.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/assets/src/sass/_theme/_typography.scss b/src/registrar/assets/src/sass/_theme/_typography.scss
index 4d89b245d..04ea9fe7c 100644
--- a/src/registrar/assets/src/sass/_theme/_typography.scss
+++ b/src/registrar/assets/src/sass/_theme/_typography.scss
@@ -27,7 +27,7 @@ h2 {
margin-top: units(2);
font-weight: font-weight('semibold');
// The units mixin can only get us close, so it's between
- // hardcoding the value and using in markup
+ // hardcoding the value and using in markup
font-size: 16.96px;
}
From 4a97525129dc331f4775404906afb8f29e5121d5 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Mon, 30 Dec 2024 10:44:12 -0800
Subject: [PATCH 059/114] Remove tagging devs requirement to code review
---
docs/dev-practices/code_review.md | 1 -
src/package-lock.json | 53 +++-------------------
src/registrar/fixtures/fixtures_domains.py | 3 +-
3 files changed, 7 insertions(+), 50 deletions(-)
diff --git a/docs/dev-practices/code_review.md b/docs/dev-practices/code_review.md
index 7b054cad5..c14e91d52 100644
--- a/docs/dev-practices/code_review.md
+++ b/docs/dev-practices/code_review.md
@@ -4,7 +4,6 @@ Pull requests should be titled in the format of `#issue_number: Descriptive name
Pull requests including a migration should be suffixed with ` - MIGRATION`
After creating a pull request, pull request submitters should:
-- Add at least 2 developers as PR reviewers (only 1 will need to approve).
- Message on Slack or in standup to notify the team that a PR is ready for review.
- If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file.
diff --git a/src/package-lock.json b/src/package-lock.json
index 22fb31857..d78b5132f 100644
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -6921,16 +6921,6 @@
"validate-npm-package-license": "^3.0.1"
}
},
- "node_modules/normalize-package-data/node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver"
- }
- },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -7307,39 +7297,6 @@
"node": ">= 12"
}
},
- "node_modules/pa11y/node_modules/lru-cache": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
- "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "license": "ISC",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/pa11y/node_modules/semver": {
- "version": "7.3.8",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
- "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
- "license": "ISC",
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/pa11y/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "license": "ISC"
- },
"node_modules/parse-filepath": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
@@ -8888,13 +8845,15 @@
}
},
"node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
}
},
"node_modules/semver-greatest-satisfied-range": {
diff --git a/src/registrar/fixtures/fixtures_domains.py b/src/registrar/fixtures/fixtures_domains.py
index 4606024d0..2b79f6963 100644
--- a/src/registrar/fixtures/fixtures_domains.py
+++ b/src/registrar/fixtures/fixtures_domains.py
@@ -39,12 +39,11 @@ class DomainFixture(DomainRequestFixture):
except Exception as e:
logger.warning(e)
return
-
# Approve each user associated with `in review` status domains
cls._approve_domain_requests(users)
@staticmethod
- def _generate_fake_expiration_date(days_in_future=365):
+ def _generate_fake_expiration_date(days_in_future=100):
"""Generates a fake expiration date between 1 and 365 days in the future."""
current_date = timezone.now().date() # nosec
return current_date + timedelta(days=random.randint(1, days_in_future)) # nosec
From f30292221492ec9d339b302e43c153c8179c5904 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Mon, 30 Dec 2024 10:58:17 -0800
Subject: [PATCH 060/114] Revert changes
---
src/registrar/fixtures/fixtures_domains.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/registrar/fixtures/fixtures_domains.py b/src/registrar/fixtures/fixtures_domains.py
index 2b79f6963..4606024d0 100644
--- a/src/registrar/fixtures/fixtures_domains.py
+++ b/src/registrar/fixtures/fixtures_domains.py
@@ -39,11 +39,12 @@ class DomainFixture(DomainRequestFixture):
except Exception as e:
logger.warning(e)
return
+
# Approve each user associated with `in review` status domains
cls._approve_domain_requests(users)
@staticmethod
- def _generate_fake_expiration_date(days_in_future=100):
+ def _generate_fake_expiration_date(days_in_future=365):
"""Generates a fake expiration date between 1 and 365 days in the future."""
current_date = timezone.now().date() # nosec
return current_date + timedelta(days=random.randint(1, days_in_future)) # nosec
From a1c5a78aeea7742f7e98caa81d26ad122deb92e0 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Mon, 30 Dec 2024 11:02:12 -0800
Subject: [PATCH 061/114] Revert package-lock.json
---
src/package-lock.json | 55 +++++++++++++++++++++++++++++++++++++------
1 file changed, 48 insertions(+), 7 deletions(-)
diff --git a/src/package-lock.json b/src/package-lock.json
index d78b5132f..e93413312 100644
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -6921,6 +6921,16 @@
"validate-npm-package-license": "^3.0.1"
}
},
+ "node_modules/normalize-package-data/node_modules/semver": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -7297,6 +7307,39 @@
"node": ">= 12"
}
},
+ "node_modules/pa11y/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pa11y/node_modules/semver": {
+ "version": "7.3.8",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+ "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pa11y/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
"node_modules/parse-filepath": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
@@ -8845,15 +8888,13 @@
}
},
"node_modules/semver": {
- "version": "7.6.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
- "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
}
},
"node_modules/semver-greatest-satisfied-range": {
@@ -10623,4 +10664,4 @@
}
}
}
-}
+}
\ No newline at end of file
From cba218fe2e019855cf9b777a6b1f6a007b900e54 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Mon, 30 Dec 2024 11:03:06 -0800
Subject: [PATCH 062/114] Revert package-lock.json
---
src/package-lock.json | 55 ++++++-------------------------------------
1 file changed, 7 insertions(+), 48 deletions(-)
diff --git a/src/package-lock.json b/src/package-lock.json
index e93413312..d78b5132f 100644
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -6921,16 +6921,6 @@
"validate-npm-package-license": "^3.0.1"
}
},
- "node_modules/normalize-package-data/node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver"
- }
- },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -7307,39 +7297,6 @@
"node": ">= 12"
}
},
- "node_modules/pa11y/node_modules/lru-cache": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
- "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "license": "ISC",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/pa11y/node_modules/semver": {
- "version": "7.3.8",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
- "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
- "license": "ISC",
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/pa11y/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "license": "ISC"
- },
"node_modules/parse-filepath": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
@@ -8888,13 +8845,15 @@
}
},
"node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
}
},
"node_modules/semver-greatest-satisfied-range": {
@@ -10664,4 +10623,4 @@
}
}
}
-}
\ No newline at end of file
+}
From b8d85429021493229c668397b1974076a7f5f9f2 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Mon, 30 Dec 2024 11:05:09 -0800
Subject: [PATCH 063/114] Revert package-lock
---
src/package-lock.json | 55 +++++++++++++++++++++++++++++++++++++------
1 file changed, 48 insertions(+), 7 deletions(-)
diff --git a/src/package-lock.json b/src/package-lock.json
index d78b5132f..e93413312 100644
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -6921,6 +6921,16 @@
"validate-npm-package-license": "^3.0.1"
}
},
+ "node_modules/normalize-package-data/node_modules/semver": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -7297,6 +7307,39 @@
"node": ">= 12"
}
},
+ "node_modules/pa11y/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pa11y/node_modules/semver": {
+ "version": "7.3.8",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+ "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pa11y/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
"node_modules/parse-filepath": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
@@ -8845,15 +8888,13 @@
}
},
"node_modules/semver": {
- "version": "7.6.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
- "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
}
},
"node_modules/semver-greatest-satisfied-range": {
@@ -10623,4 +10664,4 @@
}
}
}
-}
+}
\ No newline at end of file
From 26b24c846c6c282b088f225a80d79ae16a55cb73 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 30 Dec 2024 16:27:00 -0500
Subject: [PATCH 064/114] Fix subquery in member domains json invitation query
to handle multiple invites per email
---
src/registrar/views/portfolio_members_json.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py
index b5c608eab..962ba18ee 100644
--- a/src/registrar/views/portfolio_members_json.py
+++ b/src/registrar/views/portfolio_members_json.py
@@ -136,7 +136,10 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
# Use ArrayRemove to return an empty list when no domain invitations are found
domain_info=ArrayRemoveNull(
ArrayAgg(
- Subquery(domain_invitations.values("domain_info")),
+ # Use order_by("id")[:1] to limit the subquery to a single row,
+ # otherwise we'll trigger a "more than one row returned by a subquery used as an expression"
+ # when an email matches multiple domain invitations
+ Subquery(domain_invitations.values("domain_info").order_by("id")[:1]),
distinct=True,
)
),
From 1547a107eaad45509b822e536c96a730573a7eb7 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 30 Dec 2024 20:57:13 -0500
Subject: [PATCH 065/114] pre-concatenate
---
src/registrar/views/portfolio_members_json.py | 38 ++++++++++++++-----
1 file changed, 29 insertions(+), 9 deletions(-)
diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py
index 962ba18ee..5a81a791f 100644
--- a/src/registrar/views/portfolio_members_json.py
+++ b/src/registrar/views/portfolio_members_json.py
@@ -1,6 +1,7 @@
from django.http import JsonResponse
from django.core.paginator import Paginator
from django.db.models import Value, F, CharField, TextField, Q, Case, When, OuterRef, Subquery
+from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Cast, Coalesce, Concat
from django.contrib.postgres.aggregates import ArrayAgg
from django.urls import reverse
@@ -12,6 +13,7 @@ from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.views.utility.mixins import PortfolioMembersPermission
from registrar.models.utility.orm_helper import ArrayRemoveNull
+from django.contrib.postgres.aggregates import StringAgg
class PortfolioMembersJson(PortfolioMembersPermission, View):
@@ -119,11 +121,21 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
def initial_invitations_search(self, portfolio):
"""Perform initial invitations search and get related DomainInvitation data based on the email."""
- # Get DomainInvitation query for matching email and for the portfolio
+
+ # Subquery to get concatenated domain information for each email
domain_invitations = DomainInvitation.objects.filter(
- email=OuterRef("email"), # Check if email matches the OuterRef("email")
- domain__domain_info__portfolio=portfolio, # Check if the domain's portfolio matches the given portfolio
- ).annotate(domain_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField()))
+ email=OuterRef("email"),
+ domain__domain_info__portfolio=portfolio
+ ).annotate(
+ concatenated_info=Concat(
+ F("domain__id"), Value(":"), F("domain__name"), output_field=CharField()
+ )
+ ).values("concatenated_info")
+
+ concatenated_domain_info = domain_invitations.values("email").annotate(
+ domain_info=StringAgg("concatenated_info", delimiter=", ")
+ ).values("domain_info")
+
# PortfolioInvitation query
invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
invitations = invitations.annotate(
@@ -136,10 +148,11 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
# Use ArrayRemove to return an empty list when no domain invitations are found
domain_info=ArrayRemoveNull(
ArrayAgg(
- # Use order_by("id")[:1] to limit the subquery to a single row,
+ # We've pre-concatenated the domain infos to limit the subquery to return a single virtual 'row',
# otherwise we'll trigger a "more than one row returned by a subquery used as an expression"
- # when an email matches multiple domain invitations
- Subquery(domain_invitations.values("domain_info").order_by("id")[:1]),
+ # when an email matches multiple domain invitations.
+ # We'll take care when processing the list of one single concatenated items item in serialize_members
+ Subquery(concatenated_domain_info),
distinct=True,
)
),
@@ -156,6 +169,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
"domain_info",
"type",
)
+
return invitations
def apply_search_term(self, queryset, request):
@@ -193,6 +207,12 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
action_url = reverse(item["type"], kwargs={"pk": item["id"]})
+ # Ensure domain_info is properly processed
+ domain_info_list = item.get("domain_info", [])
+ if isinstance(domain_info_list, list) and domain_info_list:
+ # Split the first item in the list if it exists
+ domain_info_list = domain_info_list[0].split(", ")
+
# Serialize member data
member_json = {
"id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation
@@ -206,9 +226,9 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
),
# split domain_info array values into ids to form urls, and names
"domain_urls": [
- reverse("domain", kwargs={"pk": domain_info.split(":")[0]}) for domain_info in item.get("domain_info")
+ reverse("domain", kwargs={"pk": domain_info.split(":")[0]}) for domain_info in domain_info_list
],
- "domain_names": [domain_info.split(":")[1] for domain_info in item.get("domain_info")],
+ "domain_names": [domain_info.split(":")[1] for domain_info in domain_info_list],
"is_admin": is_admin,
"last_active": item.get("last_active"),
"action_url": action_url,
From 99a60c09e79401fd599b9b63cf301ae2731c6e5e Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 30 Dec 2024 21:38:35 -0500
Subject: [PATCH 066/114] revise error msg and log errors
---
src/registrar/assets/src/js/getgov/helpers.js | 12 +++++-
.../assets/src/js/getgov/table-base.js | 4 +-
.../js/getgov/table-edit-member-domains.js | 16 ++++----
.../src/js/getgov/table-member-domains.js | 24 +++++++++++-
.../includes/member_domains_table.html | 2 +-
.../portfolio_member_domains_edit.html | 3 +-
.../templates/portfolio_members_add_new.html | 1 -
src/registrar/views/portfolio_members_json.py | 29 ++++++++-------
src/registrar/views/portfolios.py | 37 ++++++++++++++++---
9 files changed, 91 insertions(+), 37 deletions(-)
diff --git a/src/registrar/assets/src/js/getgov/helpers.js b/src/registrar/assets/src/js/getgov/helpers.js
index 1afd84520..dcdcef85f 100644
--- a/src/registrar/assets/src/js/getgov/helpers.js
+++ b/src/registrar/assets/src/js/getgov/helpers.js
@@ -1,9 +1,17 @@
export function hideElement(element) {
- element.classList.add('display-none');
+ if (element) {
+ element.classList.add('display-none');
+ } else {
+ throw new Error('hideElement expected a passed DOM element as an argument, but none was provided.');
+ }
};
export function showElement(element) {
- element.classList.remove('display-none');
+ if (element) {
+ element.classList.remove('display-none');
+ } else {
+ throw new Error('showElement expected a passed DOM element as an argument, but none was provided.');
+ }
};
/**
diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js
index e526c6b5f..1232b6274 100644
--- a/src/registrar/assets/src/js/getgov/table-base.js
+++ b/src/registrar/assets/src/js/getgov/table-base.js
@@ -143,7 +143,7 @@ export class BaseTable {
this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`);
this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`);
this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`);
- this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`);
+ this.noDataTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`);
this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`);
this.portfolioElement = document.getElementById('portfolio-js-value');
this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null;
@@ -451,7 +451,7 @@ export class BaseTable {
}
// handle the display of proper messaging in the event that no members exist in the list or search returns no results
- this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
+ this.updateDisplay(data, this.tableWrapper, this.noDataTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
// identify the DOM element where the list of results will be inserted into the DOM
const tbody = this.tableWrapper.querySelector('tbody');
tbody.innerHTML = '';
diff --git a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js
index 567387bd5..86aa39c37 100644
--- a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js
+++ b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js
@@ -22,7 +22,7 @@ export class EditMemberDomainsTable extends BaseTable {
this.editModeContainer = document.getElementById('domain-assignments-edit-view');
this.readonlyModeContainer = document.getElementById('domain-assignments-readonly-view');
this.reviewButton = document.getElementById('review-domain-assignments');
- this.backButton = document.querySelectorAll('.back-to-edit-domain-assignments');
+ this.backButton = document.getElementById('back-to-edit-domain-assignments');
this.saveButton = document.getElementById('save-domain-assignments');
this.initializeDomainAssignments();
this.initCancelEditDomainAssignmentButton();
@@ -326,17 +326,15 @@ export class EditMemberDomainsTable extends BaseTable {
this.showReadonlyMode();
});
} else {
- console.warn('Missing DOM element. Expected element with id review-domain-assignments')
+ console.warn('Missing DOM element. Expected element with id review-domain-assignments');
}
- if (this.backButton.length == 2) {
- this.backButton.forEach(backbutton => {
- backbutton.addEventListener('click', () => {
- this.showEditMode();
- });
+ if (this.backButton) {
+ this.backButton.addEventListener('click', () => {
+ this.showEditMode();
});
} else {
- console.warn('Missing one or more DOM element. Expected 2 elements with class back-to-edit-domain-assignments')
+ console.warn('Missing DOM element. Expected element with id back-to-edit-domain-assignments');
}
if (this.saveButton) {
@@ -344,7 +342,7 @@ export class EditMemberDomainsTable extends BaseTable {
this.submitChanges();
});
} else {
- console.warn('Missing DOM element. Expected element with id save-domain-assignments')
+ console.warn('Missing DOM element. Expected element with id save-domain-assignments');
}
}
}
diff --git a/src/registrar/assets/src/js/getgov/table-member-domains.js b/src/registrar/assets/src/js/getgov/table-member-domains.js
index 54e9d1212..7f89eee52 100644
--- a/src/registrar/assets/src/js/getgov/table-member-domains.js
+++ b/src/registrar/assets/src/js/getgov/table-member-domains.js
@@ -1,4 +1,5 @@
+import { showElement, hideElement } from './helpers.js';
import { BaseTable } from './table-base.js';
export class MemberDomainsTable extends BaseTable {
@@ -24,7 +25,28 @@ export class MemberDomainsTable extends BaseTable {
`;
tbody.appendChild(row);
}
-
+ updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
+ const { unfiltered_total, total } = data;
+ const searchSection = document.getElementById('edit-member-domains__search');
+ if (!searchSection) console.warn('MemberDomainsTable updateDisplay expected an element with id edit-member-domains__search but none was found');
+ if (unfiltered_total) {
+ showElement(searchSection);
+ if (total) {
+ showElement(dataWrapper);
+ hideElement(noSearchResultsWrapper);
+ hideElement(noDataWrapper);
+ } else {
+ hideElement(dataWrapper);
+ showElement(noSearchResultsWrapper);
+ hideElement(noDataWrapper);
+ }
+ } else {
+ hideElement(searchSection);
+ hideElement(dataWrapper);
+ hideElement(noSearchResultsWrapper);
+ showElement(noDataWrapper);
+ }
+ };
}
export function initMemberDomainsTable() {
diff --git a/src/registrar/templates/includes/member_domains_table.html b/src/registrar/templates/includes/member_domains_table.html
index 600b9ba97..c85773b9a 100644
--- a/src/registrar/templates/includes/member_domains_table.html
+++ b/src/registrar/templates/includes/member_domains_table.html
@@ -34,7 +34,7 @@
{% endif %}
-