mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-14 05:29:43 +02:00
Merge pull request #3454 from cisagov/dk/3448-stale-invitations
#3448, #3449: Fixes for canceled and retrieved invitations
This commit is contained in:
commit
546d978d32
5 changed files with 124 additions and 20 deletions
|
@ -9,6 +9,7 @@ from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignor
|
||||||
from django.db import models, IntegrityError
|
from django.db import models, IntegrityError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from registrar.models.domain_invitation import DomainInvitation
|
||||||
from registrar.models.host import Host
|
from registrar.models.host import Host
|
||||||
from registrar.models.host_ip import HostIP
|
from registrar.models.host_ip import HostIP
|
||||||
from registrar.utility.enums import DefaultEmail
|
from registrar.utility.enums import DefaultEmail
|
||||||
|
@ -1177,6 +1178,10 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
return "DNS needed"
|
return "DNS needed"
|
||||||
return self.state.capitalize()
|
return self.state.capitalize()
|
||||||
|
|
||||||
|
def active_invitations(self):
|
||||||
|
"""Returns only the active invitations (those with status 'invited')."""
|
||||||
|
return self.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED)
|
||||||
|
|
||||||
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
|
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
|
||||||
"""Maps the Epp contact representation to a PublicContact object.
|
"""Maps the Epp contact representation to a PublicContact object.
|
||||||
|
|
||||||
|
|
|
@ -113,10 +113,10 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if value.invitations.all %}
|
{% if value.active_invitations.all %}
|
||||||
<h4 class="margin-bottom-05">Invited domain managers</h4>
|
<h4 class="margin-bottom-05">Invited domain managers</h4>
|
||||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||||
{% for item in value.invitations.all %}
|
{% for item in value.active_invitations.all %}
|
||||||
<li>{{ item.email }}</li>
|
<li>{{ item.email }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -372,6 +372,21 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
||||||
domain=domain3,
|
domain=domain3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# create another domain in the portfolio
|
||||||
|
# but make sure the domain invitation is canceled
|
||||||
|
domain4 = Domain.objects.create(
|
||||||
|
name="somedomain4.com",
|
||||||
|
)
|
||||||
|
DomainInformation.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
domain=domain4,
|
||||||
|
)
|
||||||
|
DomainInvitation.objects.create(
|
||||||
|
email=self.email6,
|
||||||
|
domain=domain4,
|
||||||
|
status=DomainInvitation.DomainInvitationStatus.CANCELED,
|
||||||
|
)
|
||||||
|
|
||||||
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json
|
data = response.json
|
||||||
|
@ -381,6 +396,7 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
||||||
self.assertIn("somedomain1.com", domain_names)
|
self.assertIn("somedomain1.com", domain_names)
|
||||||
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
|
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
|
||||||
self.assertNotIn("somedomain3.com", domain_names)
|
self.assertNotIn("somedomain3.com", domain_names)
|
||||||
|
self.assertNotIn("somedomain4.com", domain_names)
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
|
|
|
@ -22,7 +22,7 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho
|
||||||
from registrar.tests.test_views import TestWithUser
|
from registrar.tests.test_views import TestWithUser
|
||||||
from registrar.utility.email import EmailSendingError
|
from registrar.utility.email import EmailSendingError
|
||||||
from registrar.utility.errors import MissingEmailError
|
from registrar.utility.errors import MissingEmailError
|
||||||
from .common import MockSESClient, completed_domain_request, create_test_user, create_user
|
from .common import MockEppLib, MockSESClient, completed_domain_request, create_test_user, create_user
|
||||||
from waffle.testutils import override_flag
|
from waffle.testutils import override_flag
|
||||||
from django.contrib.sessions.middleware import SessionMiddleware
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
import boto3_mocking # type: ignore
|
import boto3_mocking # type: ignore
|
||||||
|
@ -3365,34 +3365,35 @@ class TestRequestingEntity(WebTest):
|
||||||
self.assertContains(response, "kepler, AL")
|
self.assertContains(response, "kepler, AL")
|
||||||
|
|
||||||
|
|
||||||
class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
|
||||||
|
|
||||||
@classmethod
|
def setUp(self):
|
||||||
def setUpClass(cls):
|
super().setUp()
|
||||||
super().setUpClass()
|
|
||||||
|
self.user = create_test_user()
|
||||||
|
|
||||||
# Create Portfolio
|
# Create Portfolio
|
||||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
|
||||||
|
|
||||||
# Add an invited member who has been invited to manage domains
|
# Add an invited member who has been invited to manage domains
|
||||||
cls.invited_member_email = "invited@example.com"
|
self.invited_member_email = "invited@example.com"
|
||||||
cls.invitation = PortfolioInvitation.objects.create(
|
self.invitation = PortfolioInvitation.objects.create(
|
||||||
email=cls.invited_member_email,
|
email=self.invited_member_email,
|
||||||
portfolio=cls.portfolio,
|
portfolio=self.portfolio,
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
additional_permissions=[
|
additional_permissions=[
|
||||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.new_member_email = "newmember@example.com"
|
self.new_member_email = "newmember@example.com"
|
||||||
|
|
||||||
AllowedEmail.objects.get_or_create(email=cls.new_member_email)
|
AllowedEmail.objects.get_or_create(email=self.new_member_email)
|
||||||
|
|
||||||
# Assign permissions to the user making requests
|
# Assign permissions to the user making requests
|
||||||
UserPortfolioPermission.objects.create(
|
UserPortfolioPermission.objects.create(
|
||||||
user=cls.user,
|
user=self.user,
|
||||||
portfolio=cls.portfolio,
|
portfolio=self.portfolio,
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
additional_permissions=[
|
additional_permissions=[
|
||||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
@ -3400,14 +3401,13 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
def tearDown(self):
|
||||||
def tearDownClass(cls):
|
|
||||||
PortfolioInvitation.objects.all().delete()
|
PortfolioInvitation.objects.all().delete()
|
||||||
UserPortfolioPermission.objects.all().delete()
|
UserPortfolioPermission.objects.all().delete()
|
||||||
Portfolio.objects.all().delete()
|
Portfolio.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
AllowedEmail.objects.all().delete()
|
AllowedEmail.objects.all().delete()
|
||||||
super().tearDownClass()
|
super().tearDown()
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
@ -3452,6 +3452,85 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
||||||
# Check that an email was sent
|
# Check that an email was sent
|
||||||
self.assertTrue(mock_client.send_email.called)
|
self.assertTrue(mock_client.send_email.called)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
|
||||||
|
def test_member_invite_for_previously_removed_user(self, mock_send_email):
|
||||||
|
"""Tests the member invitation flow for an existing member which was previously removed."""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
# invite, then retrieve an existing user, then remove the user from the portfolio
|
||||||
|
retrieved_member_email = "retrieved@example.com"
|
||||||
|
retrieved_user = User.objects.create(
|
||||||
|
username="retrieved_user",
|
||||||
|
first_name="Retrieved",
|
||||||
|
last_name="User",
|
||||||
|
email=retrieved_member_email,
|
||||||
|
phone="8003111234",
|
||||||
|
title="retrieved",
|
||||||
|
)
|
||||||
|
|
||||||
|
retrieved_invitation = PortfolioInvitation.objects.create(
|
||||||
|
email=retrieved_member_email,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
],
|
||||||
|
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||||
|
)
|
||||||
|
retrieved_invitation.retrieve()
|
||||||
|
retrieved_invitation.save()
|
||||||
|
upp = UserPortfolioPermission.objects.filter(
|
||||||
|
user=retrieved_user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
)
|
||||||
|
upp.delete()
|
||||||
|
|
||||||
|
# Simulate a session to ensure continuity
|
||||||
|
session_id = self.client.session.session_key
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
|
||||||
|
# Simulate submission of member invite for previously retrieved/removed member
|
||||||
|
final_response = self.client.post(
|
||||||
|
reverse("new-member"),
|
||||||
|
{
|
||||||
|
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||||
|
"domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||||
|
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
|
||||||
|
"member_permissions": "no_access",
|
||||||
|
"email": retrieved_member_email,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the final submission is successful
|
||||||
|
self.assertEqual(final_response.status_code, 302) # Redirects
|
||||||
|
|
||||||
|
# Validate Database Changes
|
||||||
|
# Validate that portfolio invitation was created and retrieved
|
||||||
|
self.assertFalse(
|
||||||
|
PortfolioInvitation.objects.filter(
|
||||||
|
email=retrieved_member_email,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
# at least one retrieved invitation
|
||||||
|
self.assertTrue(
|
||||||
|
PortfolioInvitation.objects.filter(
|
||||||
|
email=retrieved_member_email,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
# Ensure exactly one UserPortfolioPermission exists for the retrieved user
|
||||||
|
self.assertEqual(
|
||||||
|
UserPortfolioPermission.objects.filter(user=retrieved_user, portfolio=self.portfolio).count(),
|
||||||
|
1,
|
||||||
|
"Expected exactly one UserPortfolioPermission for the retrieved user.",
|
||||||
|
)
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
|
|
|
@ -123,7 +123,11 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
|
|
||||||
# Subquery to get concatenated domain information for each email
|
# Subquery to get concatenated domain information for each email
|
||||||
domain_invitations = (
|
domain_invitations = (
|
||||||
DomainInvitation.objects.filter(email=OuterRef("email"), domain__domain_info__portfolio=portfolio)
|
DomainInvitation.objects.filter(
|
||||||
|
email=OuterRef("email"),
|
||||||
|
domain__domain_info__portfolio=portfolio,
|
||||||
|
status=DomainInvitation.DomainInvitationStatus.INVITED,
|
||||||
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
|
concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
|
||||||
)
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue