mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-14 05:29:43 +02:00
Merge pull request #3069 from cisagov/ad/1604-allow-analysts-to-view-cancelled-invitations
#1604 allow analysts to view cancelled invitations - [AD]
This commit is contained in:
commit
bb58b77467
11 changed files with 84 additions and 48 deletions
|
@ -339,9 +339,9 @@ urlpatterns = [
|
||||||
name="user-profile",
|
name="user-profile",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"invitation/<int:pk>/delete",
|
"invitation/<int:pk>/cancel",
|
||||||
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
|
views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
|
||||||
name="invitation-delete",
|
name="invitation-cancel",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"domain-request/<int:pk>/delete",
|
"domain-request/<int:pk>/delete",
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-11-18 16:47
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import django_fsm
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0137_suborganization_city_suborganization_state_territory"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="domaininvitation",
|
||||||
|
name="status",
|
||||||
|
field=django_fsm.FSMField(
|
||||||
|
choices=[("invited", "Invited"), ("retrieved", "Retrieved"), ("canceled", "Canceled")],
|
||||||
|
default="invited",
|
||||||
|
max_length=50,
|
||||||
|
protected=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -26,6 +26,7 @@ class DomainInvitation(TimeStampedModel):
|
||||||
class DomainInvitationStatus(models.TextChoices):
|
class DomainInvitationStatus(models.TextChoices):
|
||||||
INVITED = "invited", "Invited"
|
INVITED = "invited", "Invited"
|
||||||
RETRIEVED = "retrieved", "Retrieved"
|
RETRIEVED = "retrieved", "Retrieved"
|
||||||
|
CANCELED = "canceled", "Canceled"
|
||||||
|
|
||||||
email = models.EmailField(
|
email = models.EmailField(
|
||||||
null=False,
|
null=False,
|
||||||
|
@ -73,3 +74,13 @@ class DomainInvitation(TimeStampedModel):
|
||||||
# something strange happened and this role already existed when
|
# something strange happened and this role already existed when
|
||||||
# the invitation was retrieved. Log that this occurred.
|
# the invitation was retrieved. Log that this occurred.
|
||||||
logger.warn("Invitation %s was retrieved for a role that already exists.", self)
|
logger.warn("Invitation %s was retrieved for a role that already exists.", self)
|
||||||
|
|
||||||
|
@transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED)
|
||||||
|
def cancel_invitation(self):
|
||||||
|
"""When an invitation is canceled, change the status to canceled"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED)
|
||||||
|
def update_cancellation_status(self):
|
||||||
|
"""When an invitation is canceled but reinvited, update the status to invited"""
|
||||||
|
pass
|
||||||
|
|
|
@ -145,7 +145,7 @@
|
||||||
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
|
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
|
||||||
<td>
|
<td>
|
||||||
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
|
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
|
||||||
<form method="POST" action="{% url "invitation-delete" pk=invitation.domain_invitation.id %}">
|
<form method="POST" action="{% url "invitation-cancel" pk=invitation.domain_invitation.id %}">
|
||||||
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
|
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -200,7 +200,7 @@ def is_domain_subpage(path):
|
||||||
"domain-users-add",
|
"domain-users-add",
|
||||||
"domain-request-delete",
|
"domain-request-delete",
|
||||||
"domain-user-delete",
|
"domain-user-delete",
|
||||||
"invitation-delete",
|
"invitation-cancel",
|
||||||
]
|
]
|
||||||
return get_url_name(path) in url_names
|
return get_url_name(path) in url_names
|
||||||
|
|
||||||
|
|
|
@ -726,21 +726,18 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
"""Posting to the delete view deletes an invitation."""
|
"""Posting to the delete view deletes an invitation."""
|
||||||
email_address = "mayor@igorville.gov"
|
email_address = "mayor@igorville.gov"
|
||||||
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
||||||
mock_client = MockSESClient()
|
self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
|
||||||
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
invitation = DomainInvitation.objects.get(id=invitation.id)
|
||||||
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
|
self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED)
|
||||||
mock_client.EMAILS_SENT.clear()
|
|
||||||
with self.assertRaises(DomainInvitation.DoesNotExist):
|
|
||||||
DomainInvitation.objects.get(id=invitation.id)
|
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_domain_invitation_cancel_retrieved_invitation(self):
|
def test_domain_invitation_cancel_retrieved_invitation(self):
|
||||||
"""Posting to the delete view when invitation retrieved returns an error message"""
|
"""Posting to the cancel view when invitation retrieved returns an error message"""
|
||||||
email_address = "mayor@igorville.gov"
|
email_address = "mayor@igorville.gov"
|
||||||
invitation, _ = DomainInvitation.objects.get_or_create(
|
invitation, _ = DomainInvitation.objects.get_or_create(
|
||||||
domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
|
domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
|
||||||
)
|
)
|
||||||
response = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}), follow=True)
|
response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True)
|
||||||
# Assert that an error message is displayed to the user
|
# Assert that an error message is displayed to the user
|
||||||
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
|
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
|
||||||
# Assert that the Cancel link is not displayed
|
# Assert that the Cancel link is not displayed
|
||||||
|
@ -751,7 +748,7 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_domain_invitation_cancel_no_permissions(self):
|
def test_domain_invitation_cancel_no_permissions(self):
|
||||||
"""Posting to the delete view as a different user should fail."""
|
"""Posting to the cancel view as a different user should fail."""
|
||||||
email_address = "mayor@igorville.gov"
|
email_address = "mayor@igorville.gov"
|
||||||
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
||||||
|
|
||||||
|
@ -760,7 +757,7 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
self.client.force_login(other_user)
|
self.client.force_login(other_user)
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
||||||
result = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
|
result = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
|
||||||
|
|
||||||
self.assertEqual(result.status_code, 403)
|
self.assertEqual(result.status_code, 403)
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ from .domain import (
|
||||||
DomainSecurityEmailView,
|
DomainSecurityEmailView,
|
||||||
DomainUsersView,
|
DomainUsersView,
|
||||||
DomainAddUserView,
|
DomainAddUserView,
|
||||||
DomainInvitationDeleteView,
|
DomainInvitationCancelView,
|
||||||
DomainDeleteUserView,
|
DomainDeleteUserView,
|
||||||
)
|
)
|
||||||
from .user_profile import UserProfileView, FinishProfileSetupView
|
from .user_profile import UserProfileView, FinishProfileSetupView
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Authorization is handled by the `DomainPermissionView`. To ensure that only
|
Authorization is handled by the `DomainPermissionView`. To ensure that only
|
||||||
authorized users can see information on a domain, every view here should
|
authorized users can see information on a domain, every view here should
|
||||||
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView).
|
inherit from `DomainPermissionView` (or DomainInvitationPermissionCancelView).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
@ -63,7 +63,7 @@ from epplibwrapper import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..utility.email import send_templated_email, EmailSendingError
|
from ..utility.email import send_templated_email, EmailSendingError
|
||||||
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
|
from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -914,7 +914,9 @@ class DomainUsersView(DomainBaseView):
|
||||||
has_admin_flag = True
|
has_admin_flag = True
|
||||||
break # Once we find one match, no need to check further
|
break # Once we find one match, no need to check further
|
||||||
|
|
||||||
# Add the role along with the computed flag to the list
|
# Add the role along with the computed flag to the list if the domain invitation
|
||||||
|
# if the status is not canceled
|
||||||
|
if domain_invitation.status != "canceled":
|
||||||
invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag})
|
invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag})
|
||||||
|
|
||||||
# Pass roles_with_flags to the context
|
# Pass roles_with_flags to the context
|
||||||
|
@ -985,6 +987,23 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
|
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_invite_status(self, invite, email):
|
||||||
|
"""Check if invitation status is canceled or retrieved, and gives the appropiate response"""
|
||||||
|
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
||||||
|
messages.warning(
|
||||||
|
self.request,
|
||||||
|
f"{email} is already a manager for this domain.",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
|
||||||
|
invite.update_cancellation_status()
|
||||||
|
invite.save()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# else if it has been sent but not accepted
|
||||||
|
messages.warning(self.request, f"{email} has already been invited to this domain")
|
||||||
|
return False
|
||||||
|
|
||||||
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
|
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
|
||||||
"""Performs the sending of the domain invitation email,
|
"""Performs the sending of the domain invitation email,
|
||||||
does not make a domain information object
|
does not make a domain information object
|
||||||
|
@ -1020,17 +1039,8 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
# Check to see if an invite has already been sent
|
# Check to see if an invite has already been sent
|
||||||
try:
|
try:
|
||||||
invite = DomainInvitation.objects.get(email=email, domain=self.object)
|
invite = DomainInvitation.objects.get(email=email, domain=self.object)
|
||||||
# check if the invite has already been accepted
|
# check if the invite has already been accepted or has a canceled invite
|
||||||
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
add_success = self._check_invite_status(invite, email)
|
||||||
add_success = False
|
|
||||||
messages.warning(
|
|
||||||
self.request,
|
|
||||||
f"{email} is already a manager for this domain.",
|
|
||||||
)
|
|
||||||
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")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("An error occured")
|
logger.error("An error occured")
|
||||||
|
|
||||||
|
@ -1052,6 +1062,7 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
self.object,
|
self.object,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
logger.info(exc)
|
||||||
raise EmailSendingError("Could not send email invitation.") from exc
|
raise EmailSendingError("Could not send email invitation.") from exc
|
||||||
else:
|
else:
|
||||||
if add_success:
|
if add_success:
|
||||||
|
@ -1127,11 +1138,9 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
return redirect(self.get_success_url())
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
|
||||||
# The order of the superclasses matters here. BaseDeleteView has a bug where the
|
class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView):
|
||||||
# "form_valid" function does not call super, so it cannot use SuccessMessageMixin.
|
object: DomainInvitation
|
||||||
# The workaround is to use SuccessMessageMixin first.
|
fields = []
|
||||||
class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView):
|
|
||||||
object: DomainInvitation # workaround for type mismatch in DeleteView
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Override post method in order to error in the case when the
|
"""Override post method in order to error in the case when the
|
||||||
|
@ -1139,6 +1148,8 @@ class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermission
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED:
|
if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED:
|
||||||
|
self.object.cancel_invitation()
|
||||||
|
self.object.save()
|
||||||
return self.form_valid(form)
|
return self.form_valid(form)
|
||||||
else:
|
else:
|
||||||
# Produce an error message if the domain invatation status is RETRIEVED
|
# Produce an error message if the domain invatation status is RETRIEVED
|
||||||
|
|
|
@ -5,9 +5,9 @@ from .permission_views import (
|
||||||
DomainPermissionView,
|
DomainPermissionView,
|
||||||
DomainRequestPermissionView,
|
DomainRequestPermissionView,
|
||||||
DomainRequestPermissionWithdrawView,
|
DomainRequestPermissionWithdrawView,
|
||||||
DomainInvitationPermissionDeleteView,
|
|
||||||
DomainRequestWizardPermissionView,
|
DomainRequestWizardPermissionView,
|
||||||
PortfolioMembersPermission,
|
PortfolioMembersPermission,
|
||||||
DomainRequestPortfolioViewonlyView,
|
DomainRequestPortfolioViewonlyView,
|
||||||
|
DomainInvitationPermissionCancelView,
|
||||||
)
|
)
|
||||||
from .api_views import get_senior_official_from_federal_agency_json
|
from .api_views import get_senior_official_from_federal_agency_json
|
||||||
|
|
|
@ -430,7 +430,6 @@ class DomainInvitationPermission(PermissionsLoginMixin):
|
||||||
id=self.kwargs["pk"], domain__permissions__user=self.request.user
|
id=self.kwargs["pk"], domain__permissions__user=self.request.user
|
||||||
).exists():
|
).exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import abc # abstract base class
|
import abc # abstract base class
|
||||||
|
|
||||||
from django.views.generic import DetailView, DeleteView, TemplateView
|
from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView
|
||||||
from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio
|
from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio
|
||||||
from registrar.models.user import User
|
from registrar.models.user import User
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
|
@ -156,17 +156,11 @@ class DomainRequestWizardPermissionView(DomainRequestWizardPermission, TemplateV
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteView, abc.ABC):
|
class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC):
|
||||||
"""Abstract view for deleting a domain invitation.
|
"""Abstract view for cancelling a DomainInvitation."""
|
||||||
|
|
||||||
This one is fairly specialized, but this is the only thing that we do
|
|
||||||
right now with domain invitations. We still have the full
|
|
||||||
`DomainInvitationPermission` class, but here we just pair it with a
|
|
||||||
DeleteView.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = DomainInvitation
|
model = DomainInvitation
|
||||||
object: DomainInvitation # workaround for type mismatch in DeleteView
|
object: DomainInvitation
|
||||||
|
|
||||||
|
|
||||||
class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):
|
class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue