diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 3831ea603..aa84a3405 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -339,9 +339,9 @@ name="user-profile", ), path( - "invitation//delete", - views.DomainInvitationDeleteView.as_view(http_method_names=["post"]), - name="invitation-delete", + "invitation//cancel", + views.DomainInvitationCancelView.as_view(http_method_names=["post"]), + name="invitation-cancel", ), path( "domain-request//delete", diff --git a/src/registrar/migrations/0138_alter_domaininvitation_status.py b/src/registrar/migrations/0138_alter_domaininvitation_status.py new file mode 100644 index 000000000..762a054f2 --- /dev/null +++ b/src/registrar/migrations/0138_alter_domaininvitation_status.py @@ -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, + ), + ), + ] diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index c9cbc8b39..28089dcb5 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -26,6 +26,7 @@ class Meta: class DomainInvitationStatus(models.TextChoices): INVITED = "invited", "Invited" RETRIEVED = "retrieved", "Retrieved" + CANCELED = "canceled", "Canceled" email = models.EmailField( null=False, @@ -73,3 +74,13 @@ def retrieve(self): # something strange happened and this role already existed when # the invitation was retrieved. Log that this occurred. 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 diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 7fe97233f..b8a622455 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -145,7 +145,7 @@

Invitations

{% if not portfolio %}{{ invitation.domain_invitation.status|title }}{% endif %} {% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %} -
+ {% csrf_token %}
{% endif %} diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index bd977d581..e88830156 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -200,7 +200,7 @@ def is_domain_subpage(path): "domain-users-add", "domain-request-delete", "domain-user-delete", - "invitation-delete", + "invitation-cancel", ] return get_url_name(path) in url_names diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 1b7731222..15a786c8b 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -726,21 +726,18 @@ def test_domain_invitation_cancel(self): """Posting to the delete view deletes an invitation.""" email_address = "mayor@igorville.gov" invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) - mock_client = MockSESClient() - with boto3_mocking.clients.handler_for("sesv2", mock_client): - self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) - mock_client.EMAILS_SENT.clear() - with self.assertRaises(DomainInvitation.DoesNotExist): - DomainInvitation.objects.get(id=invitation.id) + self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id})) + invitation = DomainInvitation.objects.get(id=invitation.id) + self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED) @less_console_noise_decorator 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" invitation, _ = DomainInvitation.objects.get_or_create( 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 self.assertContains(response, f"Invitation to {email_address} has already been retrieved.") # Assert that the Cancel link is not displayed @@ -751,7 +748,7 @@ def test_domain_invitation_cancel_retrieved_invitation(self): @less_console_noise_decorator 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" invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) @@ -760,7 +757,7 @@ def test_domain_invitation_cancel_no_permissions(self): self.client.force_login(other_user) mock_client = MagicMock() 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) diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index cd3f74fc8..9a9cb7856 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -11,7 +11,7 @@ DomainSecurityEmailView, DomainUsersView, DomainAddUserView, - DomainInvitationDeleteView, + DomainInvitationCancelView, DomainDeleteUserView, ) from .user_profile import UserProfileView, FinishProfileSetupView diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index c378feeb9..9bf6f5313 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -2,7 +2,7 @@ Authorization is handled by the `DomainPermissionView`. To ensure that only 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 @@ -63,7 +63,7 @@ ) from ..utility.email import send_templated_email, EmailSendingError -from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView +from .utility import DomainPermissionView, DomainInvitationPermissionCancelView logger = logging.getLogger(__name__) @@ -914,8 +914,10 @@ def _add_invitations_to_context(self, context, portfolio): has_admin_flag = True break # Once we find one match, no need to check further - # Add the role along with the computed flag to the list - invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag}) + # 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}) # Pass roles_with_flags to the context context["invitations"] = invitations @@ -985,6 +987,23 @@ def _is_member_of_different_org(self, email, requestor, requested_user): 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): """Performs the sending of the domain invitation email, does not make a domain information object @@ -1020,17 +1039,8 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u # Check to see if an invite has already been sent try: invite = DomainInvitation.objects.get(email=email, domain=self.object) - # check if the invite has already been accepted - if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: - 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") + # check if the invite has already been accepted or has a canceled invite + add_success = self._check_invite_status(invite, email) except Exception: logger.error("An error occured") @@ -1052,6 +1062,7 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u self.object, exc_info=True, ) + logger.info(exc) raise EmailSendingError("Could not send email invitation.") from exc else: if add_success: @@ -1127,11 +1138,9 @@ def form_valid(self, form): return redirect(self.get_success_url()) -# The order of the superclasses matters here. BaseDeleteView has a bug where the -# "form_valid" function does not call super, so it cannot use SuccessMessageMixin. -# The workaround is to use SuccessMessageMixin first. -class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView): - object: DomainInvitation # workaround for type mismatch in DeleteView +class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView): + object: DomainInvitation + fields = [] def post(self, request, *args, **kwargs): """Override post method in order to error in the case when the @@ -1139,6 +1148,8 @@ def post(self, request, *args, **kwargs): self.object = self.get_object() form = self.get_form() if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: + self.object.cancel_invitation() + self.object.save() return self.form_valid(form) else: # Produce an error message if the domain invatation status is RETRIEVED diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index bb135b3d3..6798eb4ee 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -5,9 +5,9 @@ DomainPermissionView, DomainRequestPermissionView, DomainRequestPermissionWithdrawView, - DomainInvitationPermissionDeleteView, DomainRequestWizardPermissionView, PortfolioMembersPermission, DomainRequestPortfolioViewonlyView, + DomainInvitationPermissionCancelView, ) from .api_views import get_senior_official_from_federal_agency_json diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index c1cf97d82..8a3d53f09 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -430,7 +430,6 @@ def has_permission(self): id=self.kwargs["pk"], domain__permissions__user=self.request.user ).exists(): return False - return True diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 1b6db24de..115b2754f 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -2,7 +2,7 @@ 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.user import User from registrar.models.user_domain_role import UserDomainRole @@ -156,17 +156,11 @@ def template_name(self): raise NotImplementedError -class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteView, abc.ABC): - """Abstract view for deleting a domain invitation. - - 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. - """ +class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC): + """Abstract view for cancelling a DomainInvitation.""" model = DomainInvitation - object: DomainInvitation # workaround for type mismatch in DeleteView + object: DomainInvitation class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):