diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 257c2e18b..43a2b708d 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -2827,8 +2827,6 @@ document.addEventListener('DOMContentLoaded', function() { */ (function handleNewMemberModal() { - - /* Populates contents of the "Add Member" confirmation modal */ @@ -2909,12 +2907,13 @@ document.addEventListener('DOMContentLoaded', function() { document.getElementById("add_member_form").addEventListener("submit", function(event) { event.preventDefault(); // Prevents the form from submitting + const form = document.getElementById("add_member_form") + const formData = new FormData(form); - const formData = new FormData(this); - - // Check if the form is valid and trigger events - // (like a confirmation modal) accordingly - fetch(this.action, { + // Check if the form is valid + // If the form is valid, open the confirmation modal + // If the form is invalid, submit it to trigger error + fetch(form.action, { method: "POST", body: formData, headers: { @@ -2929,7 +2928,7 @@ document.addEventListener('DOMContentLoaded', function() { openAddMemberConfirmationModal(); } else { // If the form is not valid, trigger error messages by firing a submit event - this.submit(); + form.submit(); } }); }); diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 58a8581dd..18235d399 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -126,11 +126,6 @@ urlpatterns = [ views.NewMemberView.as_view(), name="new-member", ), - path( - "members/new-member/validate", - views.NewMemberView.as_view(http_method_names=["post"]), - name="new-member-validate", - ), path( "requests/", views.PortfolioDomainRequestsView.as_view(), diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index e369ed97b..655b01852 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -125,7 +125,7 @@ aria-controls="invite-member-modal" data-open-modal >Trigger invite member modal - + diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index a50a78b23..eec98b81d 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -1876,3 +1876,156 @@ class TestRequestingEntity(WebTest): self.assertContains(response, "Requesting entity") self.assertContains(response, "moon") self.assertContains(response, "kepler, AL") + + +class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + + # Create Portfolio + cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio") + + # Add an invited member who has been invited to manage domains + cls.invited_member_email = "invited@example.com" + cls.invitation = PortfolioInvitation.objects.create( + email=cls.invited_member_email, + portfolio=cls.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + cls.new_member_email = "new_user@example.com" + + # Assign permissions to the user making requests + UserPortfolioPermission.objects.create( + user=cls.user, + portfolio=cls.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + @classmethod + def tearDownClass(cls): + PortfolioInvitation.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + Portfolio.objects.all().delete() + User.objects.all().delete() + super().tearDownClass() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_invite_for_new_users(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) + + # Step 1: Access the "New Member" page + new_member_page = self.app.get(reverse("new-member")) + self.assertEqual(new_member_page.status_code, 200) + self.assertContains(new_member_page, "Add a new member") + self.assertContains(new_member_page, f'
') + + # Step 2: Fill out the "New Member" form + new_member_form = new_member_page.forms[0] + new_member_form["member_access_level"] = "basic" + new_member_form["basic_org_domain_request_permissions"] = "view_only" + new_member_form["email"] = self.new_member_email + + # Simulate form submission, which would trigger the modal in JavaScript + response = new_member_form.submit().follow() + self.assertEqual(response.status_code, 301) # Ensure the page does not redirect + + + # TODO: test the modal somehow + # self.assertContains(new_member_page, f'{self.new_member_email}
') + # form_data = {field.name: field.value() for field in new_member_form} + + + # Simulate user confirming the modal action (frontend JavaScript would normally handle this) + # Re-submit the form to simulate final submission + final_response = self.client.post(reverse("new-member"), { + "member_access_level": "basic", + "basic_org_domain_request_permissions": "view_only", + "email": self.new_member_email + }) + + # Ensure the final submission is successful + self.assertEqual(final_response.status_code, 302) # redirects after success + + # TODO: verify messages + + # Step 4: 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) + # self.assertEqual(portfolio_invite.access_level, "basic") # TODO: test that roles and permissions are in the portfolio invite + + + @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): + """Tests 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"), { + "member_access_level": "basic", + "basic_org_domain_request_permissions": "view_only", + "email": self.invited_member_email + }) + self.assertEqual(response.status_code, 302) # Redirects + + # TODO: verify messages + + # Validate Database has not changed + invite_count_after = PortfolioInvitation.objects.count() + self.assertEqual(invite_count_after, invite_count_before) + + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_invite_for_existing_member(self): + """Tests 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"), { + "member_access_level": "basic", + "basic_org_domain_request_permissions": "view_only", + "email": self.user.email + }) + self.assertEqual(response.status_code, 302) # Redirects + + # TODO: verify messages + + # Validate Database has not changed + invite_count_after = PortfolioInvitation.objects.count() + self.assertEqual(invite_count_after, invite_count_before) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 899083336..9c3a0677a 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -23,9 +23,17 @@ from registrar.views.utility.permission_views import ( from django.views.generic import View from django.views.generic.edit import FormMixin + + +# ---Logger +import logging +from venv import logger +from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper logger = logging.getLogger(__name__) + + class PortfolioDomainsView(PortfolioDomainsPermissionView, View): template_name = "portfolio_domains.html" @@ -418,6 +426,7 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): """Handle POST requests to process form submission.""" self.object = self.get_object() form = self.get_form() + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"**post") if form.is_valid(): return self.form_valid(form) @@ -433,6 +442,17 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): else: return super().form_invalid(form) # Handle non-AJAX requests normally + def form_valid(self, form): + + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"VALIDATING") + + + if self.is_ajax(): + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"IS AJAX") + return JsonResponse({"is_valid": True}) # Return a JSON response + else: + return self.submit_new_member(form) + def get_success_url(self): """Redirect to members table.""" return reverse("members") @@ -446,6 +466,8 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): raises EmailSendingError """ + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"_send_portfolio_invitation_email") + # Set a default email address to send to for staff requestor_email = settings.DEFAULT_FROM_EMAIL @@ -469,12 +491,13 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): add_success = False messages.warning( self.request, - f"{email} is already a manager for this domain.", + f"{email} is already a manager for this portfolio.", ) else: add_success = False # else if it has been sent but not accepted - messages.warning(self.request, f"{email} has already been invited to this domain") + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"has already been invited to this portfolio") + messages.warning(self.request, f"{email} has already been invited to this portfolio") except Exception: logger.error("An error occured") @@ -513,16 +536,11 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): PortfolioInvitation.objects.get_or_create(email=email_address, portfolio=self.object) return redirect(self.get_success_url()) - def form_valid(self, form): - if self.is_ajax(): - return JsonResponse({"is_valid": True}) # Return a JSON response - else: - return self.submit_new_member(form) - def submit_new_member(self, form): """Add the specified user as a member for this portfolio. Throws EmailSendingError.""" + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"Submit new member") requested_email = form.cleaned_data["email"] requestor = self.request.user @@ -532,6 +550,8 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): requested_user = User.objects.get(email=requested_email) except User.DoesNotExist: # no matching user, go make an invitation + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"..making invitation") + return self._make_invitation(requested_email, requestor) else: # If user already exists, check to see if they are part of the portfolio already @@ -539,9 +559,11 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): existing_user = UserPortfolioPermission.objects.get(user=requested_user, portfolio=self.object) if existing_user: messages.warning(self.request, "User is already a member of this portfolio.") + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"already a member") else: try: self._send_portfolio_invitation_email(requested_email, requestor, add_success=False) + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"..SEnding invitation") except EmailSendingError: logger.warn( "Could not send email invitation (EmailSendingError)",