Merge branch 'za/1501-users-delete-domain-records' into za/1484-domain-manager-delete

This commit is contained in:
zandercymatics 2024-01-22 11:18:29 -07:00
commit 99ee850238
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
7 changed files with 250 additions and 37 deletions

View file

@ -631,7 +631,6 @@ class DomainApplication(TimeStampedModel):
# Update submission_date to today
self.submission_date = timezone.now().date()
self.save()
self._send_status_update_email(

View file

@ -57,6 +57,9 @@ class DomainHelper:
# If blank ok is true, just return the domain
return domain
if domain.startswith("www."):
domain = domain[4:]
if domain.endswith(".gov"):
domain = domain[:-4]

View file

@ -38,11 +38,6 @@
>
<span class="usa-sr-only">Action</span>
</th>
{% comment %}
#1510
{% if has_deletable_applications %}
<th></th>
{% endif %} {% endcomment %}
</tr>
</thead>
<tbody>
@ -117,8 +112,9 @@
<th th scope="row" role="rowheader" data-label="Domain name">
{% if application.requested_domain is None %}
New domain request
<br>
<span class="text-base font-body-xs">({{ application.created_at }})</span>
{# Add a breakpoint #}
<div aria-hidden="true"></div>
<span class="text-base font-body-xs">({{ application.created_at }} UTC)</span>
{% else %}
{{ application.requested_domain.name }}
{% endif %}
@ -132,19 +128,29 @@
</td>
<td data-label="Status">{{ application.get_status_display }}</td>
<td>
{% if application.status == "started" or application.status == "action needed" or application.status == "withdrawn" %}
<a href="{% url 'edit-application' application.pk %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#edit"></use>
</svg>
Edit <span class="usa-sr-only">{{ application.requested_domain.name|default:"New domain request ("|add:application.created_at|add:")" }}</span>
{% else %}
<a href="{% url 'application-status' application.pk %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#settings"></use>
</svg>
Manage <span class="usa-sr-only">{{ application.requested_domain.name|default:"New domain request ("|add:application.created_at|add:")" }}</span>
{% endif %}
{% with prefix="New domain request ("%}
{% with date=application.created_at|date:"DATETIME_FORMAT"%}
{% with name_default=prefix|add:date|add:" UTC)"%}
{% if application.status == application.ApplicationStatus.STARTED or application.status == application.ApplicationStatus.ACTION_NEEDED or application.status == application.ApplicationStatus.WITHDRAWN %}
<a href="{% url 'edit-application' application.pk %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#edit"></use>
</svg>
{% if application.requested_domain is not None%}
Edit <span class="usa-sr-only">{{ application.requested_domain.name }}</span>
{% else %}
Edit <span class="usa-sr-only">{{ name_default }}</span>
{% endif %}
{% else %}
<a href="{% url 'application-status' application.pk %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#settings"></use>
</svg>
Manage <span class="usa-sr-only">{{ application.requested_domain.name|default:name_default }}</span>
{% endif %}
{% endwith %}
{% endwith %}
{% endwith %}
</a>
</td>
{% if has_deletable_applications %}
@ -161,7 +167,17 @@
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>
Delete <span class="usa-sr-only">{{ application.requested_domain.name|default:"New domain request ("|add:application.created_at|add:")" }}</span>
{% with prefix="New domain request ("%}
{% with date=application.created_at|date:"DATETIME_FORMAT"%}
{% with name_default=prefix|add:date|add:" UTC)"%}
{% if application.requested_domain is not None %}
Delete <span class="usa-sr-only">{{ application.requested_domain.name }}</span>
{% else %}
Delete <span class="usa-sr-only">{{ name_default }}</span>
{% endif %}
{% endwith %}
{% endwith %}
{% endwith %}
</a>
<div
@ -173,17 +189,17 @@
>
<form method="POST" action="{% url "application-delete" pk=application.id %}">
{% if application.requested_domain is None %}
{% with prefix="New domain request (" %}
{% if application.created_at %}
{% with formatted_date=application.created_at|date:"DATETIME_FORMAT" %}
{% with modal_content=prefix|add:formatted_date|add:")" %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete" heading_value="New domain request?" modal_description="This will remove "|add:modal_content|add:" from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endwith %}
{% if application.created_at %}
{% with prefix="(created " %}
{% with formatted_date=application.created_at|date:"DATETIME_FORMAT" %}
{% with modal_content=prefix|add:formatted_date|add:" UTC)" %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete this domain request?" modal_description="This will remove the domain request "|add:modal_content|add:" from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endwith %}
{% else %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete New domain request?" modal_description="This will remove the domain request from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endif %}
{% endwith %}
{% endwith %}
{% endwith %}
{% else %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete New domain request?" modal_description="This will remove the domain request from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endif %}
{% else %}
{% with modal_heading_value=application.requested_domain.name|add:"?" %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete" heading_value=modal_heading_value modal_description="This will remove the domain request from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}

View file

@ -5,7 +5,8 @@
<h2 class="usa-modal__heading" id="modal-1-heading">
{{ modal_heading }}
{% if heading_value is not None %}
<br>
{# Add a breakpoint #}
<div aria-hidden="true"></div>
{{ heading_value }}
{% endif %}
</h2>

View file

@ -89,6 +89,10 @@ class LoggedInTests(TestWithUser):
super().setUp()
self.client.force_login(self.user)
def tearDown(self):
super().tearDown()
Contact.objects.all().delete()
def test_home_lists_domain_applications(self):
response = self.client.get("/")
self.assertNotContains(response, "igorville.gov")
@ -96,8 +100,8 @@ class LoggedInTests(TestWithUser):
application = DomainApplication.objects.create(creator=self.user, requested_domain=site)
response = self.client.get("/")
# count = 5 because of screenreader content
self.assertContains(response, "igorville.gov", count=5)
# count = 7 because of screenreader content
self.assertContains(response, "igorville.gov", count=7)
# clean up
application.delete()
@ -182,6 +186,132 @@ class LoggedInTests(TestWithUser):
# clean up
application.delete()
def test_home_deletes_domain_application_and_orphans(self):
"""Tests if delete for DomainApplication deletes orphaned Contact objects"""
# Create the site and contacts to delete (orphaned)
contact = Contact.objects.create(
first_name="Henry",
last_name="Mcfakerson",
)
contact_shared = Contact.objects.create(
first_name="Relative",
last_name="Aether",
)
# Create two non-orphaned contacts
contact_2 = Contact.objects.create(
first_name="Saturn",
last_name="Mars",
)
# Attach a user object to a contact (should not be deleted)
contact_user, _ = Contact.objects.get_or_create(user=self.user)
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user,
requested_domain=site,
status=DomainApplication.ApplicationStatus.WITHDRAWN,
authorizing_official=contact,
submitter=contact_user,
)
application.other_contacts.set([contact_2])
# Create a second application to attach contacts to
site_2 = DraftDomain.objects.create(name="teaville.gov")
application_2 = DomainApplication.objects.create(
creator=self.user,
requested_domain=site_2,
status=DomainApplication.ApplicationStatus.STARTED,
authorizing_official=contact_2,
submitter=contact_shared,
)
application_2.other_contacts.set([contact_shared])
# Ensure that igorville.gov exists on the page
home_page = self.client.get("/")
self.assertContains(home_page, "igorville.gov")
# Trigger the delete logic
response = self.client.post(reverse("application-delete", kwargs={"pk": application.pk}), follow=True)
self.assertNotContains(response, "igorville.gov")
# Check if the orphaned contact was deleted
orphan = Contact.objects.filter(id=contact.id)
self.assertFalse(orphan.exists())
# All non-orphan contacts should still exist and are unaltered
try:
current_user = Contact.objects.filter(id=contact_user.id).get()
except Contact.DoesNotExist:
self.fail("contact_user (a non-orphaned contact) was deleted")
self.assertEqual(current_user, contact_user)
try:
edge_case = Contact.objects.filter(id=contact_2.id).get()
except Contact.DoesNotExist:
self.fail("contact_2 (a non-orphaned contact) was deleted")
self.assertEqual(edge_case, contact_2)
def test_home_deletes_domain_application_and_shared_orphans(self):
"""Test the edge case for an object that will become orphaned after a delete
(but is not an orphan at the time of deletion)"""
# Create the site and contacts to delete (orphaned)
contact = Contact.objects.create(
first_name="Henry",
last_name="Mcfakerson",
)
contact_shared = Contact.objects.create(
first_name="Relative",
last_name="Aether",
)
# Create two non-orphaned contacts
contact_2 = Contact.objects.create(
first_name="Saturn",
last_name="Mars",
)
# Attach a user object to a contact (should not be deleted)
contact_user, _ = Contact.objects.get_or_create(user=self.user)
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user,
requested_domain=site,
status=DomainApplication.ApplicationStatus.WITHDRAWN,
authorizing_official=contact,
submitter=contact_user,
)
application.other_contacts.set([contact_2])
# Create a second application to attach contacts to
site_2 = DraftDomain.objects.create(name="teaville.gov")
application_2 = DomainApplication.objects.create(
creator=self.user,
requested_domain=site_2,
status=DomainApplication.ApplicationStatus.STARTED,
authorizing_official=contact_2,
submitter=contact_shared,
)
application_2.other_contacts.set([contact_shared])
home_page = self.client.get("/")
self.assertContains(home_page, "teaville.gov")
# Trigger the delete logic
response = self.client.post(reverse("application-delete", kwargs={"pk": application_2.pk}), follow=True)
self.assertNotContains(response, "teaville.gov")
# Check if the orphaned contact was deleted
orphan = Contact.objects.filter(id=contact_shared.id)
self.assertFalse(orphan.exists())
def test_application_form_view(self):
response = self.client.get("/request/", follow=True)
self.assertContains(

View file

@ -1,5 +1,5 @@
import logging
from collections import defaultdict
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import resolve, reverse
@ -10,6 +10,7 @@ from django.contrib import messages
from registrar.forms import application_wizard as forms
from registrar.models import DomainApplication
from registrar.models.contact import Contact
from registrar.models.user import User
from registrar.utility import StrEnum
from registrar.views.utility import StepsHelper
@ -649,3 +650,64 @@ class DomainApplicationDeleteView(DomainApplicationPermissionDeleteView):
def get_success_url(self):
"""After a delete is successful, redirect to home"""
return reverse("home")
def post(self, request, *args, **kwargs):
# Grab all orphaned contacts
application: DomainApplication = self.get_object()
contacts_to_delete, duplicates = self._get_orphaned_contacts(application)
# Delete the DomainApplication
response = super().post(request, *args, **kwargs)
# Delete orphaned contacts - but only for if they are not associated with a user
Contact.objects.filter(id__in=contacts_to_delete, user=None).delete()
# After a delete occurs, do a second sweep on any returned duplicates.
# This determines if any of these three fields share a contact, which is used for
# the edge case where the same user may be an AO, and a submitter, for example.
if len(duplicates) > 0:
duplicates_to_delete, _ = self._get_orphaned_contacts(application)
Contact.objects.filter(id__in=duplicates_to_delete, user=None).delete()
return response
def _get_orphaned_contacts(self, application: DomainApplication, check_db=True):
"""Collects all orphaned contacts"""
contacts_to_delete = []
# Get each contact object on the DomainApplication object
ao = application.authorizing_official
submitter = application.submitter
other_contacts = list(application.other_contacts.all())
other_contact_ids = application.other_contacts.all().values_list("id", flat=True)
# Check if the desired item still exists in the DB
if check_db:
ao = self._get_contacts_by_id([ao.id]).first() if ao is not None else None
submitter = self._get_contacts_by_id([submitter.id]).first() if submitter is not None else None
other_contacts = self._get_contacts_by_id(other_contact_ids)
# Pair each contact with its related name
checked_contacts = [(ao, "authorizing_official"), (submitter, "submitted_applications")]
checked_contacts.extend((contact, "contact_applications") for contact in other_contacts)
for contact, related_name in checked_contacts:
if contact is not None and not contact.has_more_than_one_join(related_name):
contacts_to_delete.append(contact.id)
return (contacts_to_delete, self._get_duplicates(checked_contacts))
def _get_contacts_by_id(self, contact_ids):
"""Given a list of ids, grab contacts if it exists"""
contacts = Contact.objects.filter(id__in=contact_ids)
return contacts
def _get_duplicates(self, objects):
"""Given a list of objects, return a list of which items were duplicates"""
# Gets the occurence count
object_dict = defaultdict(int)
for contact, _related in objects:
object_dict[contact] += 1
duplicates = [item for item, count in object_dict.items() if count > 1]
return duplicates

View file

@ -42,7 +42,9 @@ def _get_applications(request):
# Let's exclude the approved applications since our
# domain_applications context will be used to populate
# the active applications table
applications = DomainApplication.objects.filter(creator=request.user).exclude(status="approved")
applications = DomainApplication.objects.filter(creator=request.user).exclude(
status=DomainApplication.ApplicationStatus.APPROVED
)
# Create a placeholder DraftDomain for each incomplete draft
valid_statuses = [DomainApplication.ApplicationStatus.STARTED, DomainApplication.ApplicationStatus.WITHDRAWN]