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:
asaki222 2024-11-18 16:04:43 -05:00 committed by GitHub
commit bb58b77467
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 84 additions and 48 deletions

View file

@ -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",

View file

@ -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,
),
),
]

View file

@ -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

View file

@ -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 %}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):