Merge pull request #1718 from cisagov/rjm/1674-approved-inreview-approved

Issue 1674: Add FSM rules for the new transitions from approv
This commit is contained in:
rachidatecs 2024-02-07 14:08:27 -05:00 committed by GitHub
commit 5faa438956
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 118 additions and 140 deletions

View file

@ -884,14 +884,11 @@ class DomainApplicationAdmin(ListHeaderAdmin):
if ( if (
obj obj
and original_obj.status == models.DomainApplication.ApplicationStatus.APPROVED and original_obj.status == models.DomainApplication.ApplicationStatus.APPROVED
and ( and obj.status != models.DomainApplication.ApplicationStatus.APPROVED
obj.status == models.DomainApplication.ApplicationStatus.REJECTED
or obj.status == models.DomainApplication.ApplicationStatus.INELIGIBLE
)
and not obj.domain_is_not_active() and not obj.domain_is_not_active()
): ):
# If an admin tried to set an approved application to # If an admin tried to set an approved application to
# rejected or ineligible and the related domain is already # another status and the related domain is already
# active, shortcut the action and throw a friendly # active, shortcut the action and throw a friendly
# error message. This action would still not go through # error message. This action would still not go through
# shortcut or not as the rules are duplicated on the model, # shortcut or not as the rules are duplicated on the model,

View file

@ -578,6 +578,19 @@ class DomainApplication(TimeStampedModel):
return not self.approved_domain.is_active() return not self.approved_domain.is_active()
return True return True
def delete_and_clean_up_domain(self, called_from):
try:
domain_state = self.approved_domain.state
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
self.approved_domain.deletedInEpp()
self.approved_domain.save()
self.approved_domain.delete()
self.approved_domain = None
except Exception as err:
logger.error(err)
logger.error(f"Can't query an approved domain while attempting {called_from}")
def _send_status_update_email(self, new_status, email_template, email_template_subject, send_email=True): def _send_status_update_email(self, new_status, email_template, email_template_subject, send_email=True):
"""Send a status update email to the submitter. """Send a status update email to the submitter.
@ -657,11 +670,19 @@ class DomainApplication(TimeStampedModel):
ApplicationStatus.INELIGIBLE, ApplicationStatus.INELIGIBLE,
], ],
target=ApplicationStatus.IN_REVIEW, target=ApplicationStatus.IN_REVIEW,
conditions=[domain_is_not_active],
) )
def in_review(self): def in_review(self):
"""Investigate an application that has been submitted. """Investigate an application that has been submitted.
This action is logged.""" This action is logged.
As side effects this will delete the domain and domain_information
(will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED:
self.delete_and_clean_up_domain("in_review")
literal = DomainApplication.ApplicationStatus.IN_REVIEW literal = DomainApplication.ApplicationStatus.IN_REVIEW
# Check if the tuple exists, then grab its value # Check if the tuple exists, then grab its value
in_review = literal if literal is not None else "In Review" in_review = literal if literal is not None else "In Review"
@ -676,11 +697,19 @@ class DomainApplication(TimeStampedModel):
ApplicationStatus.INELIGIBLE, ApplicationStatus.INELIGIBLE,
], ],
target=ApplicationStatus.ACTION_NEEDED, target=ApplicationStatus.ACTION_NEEDED,
conditions=[domain_is_not_active],
) )
def action_needed(self): def action_needed(self):
"""Send back an application that is under investigation or rejected. """Send back an application that is under investigation or rejected.
This action is logged.""" This action is logged.
As side effects this will delete the domain and domain_information
(will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED:
self.delete_and_clean_up_domain("reject_with_prejudice")
literal = DomainApplication.ApplicationStatus.ACTION_NEEDED literal = DomainApplication.ApplicationStatus.ACTION_NEEDED
# Check if the tuple is setup correctly, then grab its value # Check if the tuple is setup correctly, then grab its value
action_needed = literal if literal is not None else "Action Needed" action_needed = literal if literal is not None else "Action Needed"
@ -752,18 +781,9 @@ class DomainApplication(TimeStampedModel):
As side effects this will delete the domain and domain_information As side effects this will delete the domain and domain_information
(will cascade), and send an email notification.""" (will cascade), and send an email notification."""
if self.status == self.ApplicationStatus.APPROVED: if self.status == self.ApplicationStatus.APPROVED:
try: self.delete_and_clean_up_domain("reject")
domain_state = self.approved_domain.state
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
self.approved_domain.deletedInEpp()
self.approved_domain.save()
self.approved_domain.delete()
self.approved_domain = None
except Exception as err:
logger.error(err)
logger.error("Can't query an approved domain while attempting a DA reject()")
self._send_status_update_email( self._send_status_update_email(
"action needed", "action needed",
@ -792,17 +812,7 @@ class DomainApplication(TimeStampedModel):
and domain_information (will cascade) when they exist.""" and domain_information (will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED: if self.status == self.ApplicationStatus.APPROVED:
try: self.delete_and_clean_up_domain("reject_with_prejudice")
domain_state = self.approved_domain.state
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
self.approved_domain.deletedInEpp()
self.approved_domain.save()
self.approved_domain.delete()
self.approved_domain = None
except Exception as err:
logger.error(err)
logger.error("Can't query an approved domain while attempting a DA reject_with_prejudice()")
self.creator.restrict_user() self.creator.restrict_user()

View file

@ -699,41 +699,13 @@ class TestDomainApplicationAdmin(MockEppLib):
"Cannot edit an application with a restricted creator.", "Cannot edit an application with a restricted creator.",
) )
@boto3_mocking.patching def trigger_saving_approved_to_another_state(self, domain_is_active, another_state):
def test_error_when_saving_approved_to_rejected_and_domain_is_active(self): """Helper method that triggers domain request state changes from approved to another state,
# Create an instance of the model with an associated domain that can be either active (READY) or not.
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name)
application.approved_domain = domain
application.save()
# Create a request object with a superuser Used to test errors when saving a change with an active domain, also used to test side effects
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk)) when saving a change goes through."""
request.user = self.superuser
# Define a custom implementation for is_active
def custom_is_active(self):
return True # Override to return True
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error"))
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.REJECTED
self.admin.save_model(request, application, None, True)
# Assert that the error message was called with the correct argument
messages.error.assert_called_once_with(
request,
"This action is not permitted. The domain " + "is already active.",
)
def test_side_effects_when_saving_approved_to_rejected(self):
# Create an instance of the model # Create an instance of the model
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED) application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name) domain = Domain.objects.create(name=application.requested_domain.name)
@ -747,101 +719,60 @@ class TestDomainApplicationAdmin(MockEppLib):
# Define a custom implementation for is_active # Define a custom implementation for is_active
def custom_is_active(self): def custom_is_active(self):
return False # Override to return False return domain_is_active # Override to return True
# Use ExitStack to combine patch contexts # Use ExitStack to combine patch contexts
with ExitStack() as stack: with ExitStack() as stack:
# Patch Domain.is_active and django.contrib.messages.error simultaneously # Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active)) stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error")) stack.enter_context(patch.object(messages, "error"))
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.REJECTED
self.admin.save_model(request, application, None, True)
# Assert that the error message was never called application.status = another_state
messages.error.assert_not_called() self.admin.save_model(request, application, None, True)
self.assertEqual(application.approved_domain, None) # Assert that the error message was called with the correct argument
if domain_is_active:
messages.error.assert_called_once_with(
request,
"This action is not permitted. The domain " + "is already active.",
)
else:
# Assert that the error message was never called
messages.error.assert_not_called()
# Assert that Domain got Deleted self.assertEqual(application.approved_domain, None)
with self.assertRaises(Domain.DoesNotExist):
domain.refresh_from_db()
# Assert that DomainInformation got Deleted # Assert that Domain got Deleted
with self.assertRaises(DomainInformation.DoesNotExist): with self.assertRaises(Domain.DoesNotExist):
domain_information.refresh_from_db() domain.refresh_from_db()
# Assert that DomainInformation got Deleted
with self.assertRaises(DomainInformation.DoesNotExist):
domain_information.refresh_from_db()
def test_error_when_saving_approved_to_in_review_and_domain_is_active(self):
self.trigger_saving_approved_to_another_state(True, DomainApplication.ApplicationStatus.IN_REVIEW)
def test_error_when_saving_approved_to_action_needed_and_domain_is_active(self):
self.trigger_saving_approved_to_another_state(True, DomainApplication.ApplicationStatus.ACTION_NEEDED)
def test_error_when_saving_approved_to_rejected_and_domain_is_active(self):
self.trigger_saving_approved_to_another_state(True, DomainApplication.ApplicationStatus.REJECTED)
def test_error_when_saving_approved_to_ineligible_and_domain_is_active(self): def test_error_when_saving_approved_to_ineligible_and_domain_is_active(self):
# Create an instance of the model self.trigger_saving_approved_to_another_state(True, DomainApplication.ApplicationStatus.INELIGIBLE)
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name)
application.approved_domain = domain
application.save()
# Create a request object with a superuser def test_side_effects_when_saving_approved_to_in_review(self):
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk)) self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.IN_REVIEW)
request.user = self.superuser
# Define a custom implementation for is_active def test_side_effects_when_saving_approved_to_action_needed(self):
def custom_is_active(self): self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.ACTION_NEEDED)
return True # Override to return True
# Use ExitStack to combine patch contexts def test_side_effects_when_saving_approved_to_rejected(self):
with ExitStack() as stack: self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.REJECTED)
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error"))
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.INELIGIBLE
self.admin.save_model(request, application, None, True)
# Assert that the error message was called with the correct argument
messages.error.assert_called_once_with(
request,
"This action is not permitted. The domain " + "is already active.",
)
def test_side_effects_when_saving_approved_to_ineligible(self): def test_side_effects_when_saving_approved_to_ineligible(self):
# Create an instance of the model self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.INELIGIBLE)
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name)
domain_information = DomainInformation.objects.create(creator=self.superuser, domain=domain)
application.approved_domain = domain
application.save()
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
request.user = self.superuser
# Define a custom implementation for is_active
def custom_is_active(self):
return False # Override to return False
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error"))
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.INELIGIBLE
self.admin.save_model(request, application, None, True)
# Assert that the error message was never called
messages.error.assert_not_called()
self.assertEqual(application.approved_domain, None)
# Assert that Domain got Deleted
with self.assertRaises(Domain.DoesNotExist):
domain.refresh_from_db()
# Assert that DomainInformation got Deleted
with self.assertRaises(DomainInformation.DoesNotExist):
domain_information.refresh_from_db()
def test_has_correct_filters(self): def test_has_correct_filters(self):
""" """

View file

@ -464,6 +464,46 @@ class TestDomainApplication(TestCase):
with self.assertRaises(exception_type): with self.assertRaises(exception_type):
application.reject_with_prejudice() application.reject_with_prejudice()
def test_transition_not_allowed_approved_in_review_when_domain_is_active(self):
"""Create an application with status approved, create a matching domain that
is active, and call in_review against transition rules"""
domain = Domain.objects.create(name=self.approved_application.requested_domain.name)
self.approved_application.approved_domain = domain
self.approved_application.save()
# Define a custom implementation for is_active
def custom_is_active(self):
return True # Override to return True
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Use patch to temporarily replace is_active with the custom implementation
with patch.object(Domain, "is_active", custom_is_active):
# Now, when you call is_active on Domain, it will return True
with self.assertRaises(TransitionNotAllowed):
self.approved_application.in_review()
def test_transition_not_allowed_approved_action_needed_when_domain_is_active(self):
"""Create an application with status approved, create a matching domain that
is active, and call action_needed against transition rules"""
domain = Domain.objects.create(name=self.approved_application.requested_domain.name)
self.approved_application.approved_domain = domain
self.approved_application.save()
# Define a custom implementation for is_active
def custom_is_active(self):
return True # Override to return True
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Use patch to temporarily replace is_active with the custom implementation
with patch.object(Domain, "is_active", custom_is_active):
# Now, when you call is_active on Domain, it will return True
with self.assertRaises(TransitionNotAllowed):
self.approved_application.action_needed()
def test_transition_not_allowed_approved_rejected_when_domain_is_active(self): def test_transition_not_allowed_approved_rejected_when_domain_is_active(self):
"""Create an application with status approved, create a matching domain that """Create an application with status approved, create a matching domain that
is active, and call reject against transition rules""" is active, and call reject against transition rules"""