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 # Update submission_date to today
self.submission_date = timezone.now().date() self.submission_date = timezone.now().date()
self.save() self.save()
self._send_status_update_email( self._send_status_update_email(

View file

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

View file

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

View file

@ -89,6 +89,10 @@ class LoggedInTests(TestWithUser):
super().setUp() super().setUp()
self.client.force_login(self.user) self.client.force_login(self.user)
def tearDown(self):
super().tearDown()
Contact.objects.all().delete()
def test_home_lists_domain_applications(self): def test_home_lists_domain_applications(self):
response = self.client.get("/") response = self.client.get("/")
self.assertNotContains(response, "igorville.gov") self.assertNotContains(response, "igorville.gov")
@ -96,8 +100,8 @@ class LoggedInTests(TestWithUser):
application = DomainApplication.objects.create(creator=self.user, requested_domain=site) application = DomainApplication.objects.create(creator=self.user, requested_domain=site)
response = self.client.get("/") response = self.client.get("/")
# count = 5 because of screenreader content # count = 7 because of screenreader content
self.assertContains(response, "igorville.gov", count=5) self.assertContains(response, "igorville.gov", count=7)
# clean up # clean up
application.delete() application.delete()
@ -182,6 +186,132 @@ class LoggedInTests(TestWithUser):
# clean up # clean up
application.delete() 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): def test_application_form_view(self):
response = self.client.get("/request/", follow=True) response = self.client.get("/request/", follow=True)
self.assertContains( self.assertContains(

View file

@ -1,5 +1,5 @@
import logging import logging
from collections import defaultdict
from django.http import Http404, HttpResponse, HttpResponseRedirect from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import resolve, reverse 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.forms import application_wizard as forms
from registrar.models import DomainApplication from registrar.models import DomainApplication
from registrar.models.contact import Contact
from registrar.models.user import User from registrar.models.user import User
from registrar.utility import StrEnum from registrar.utility import StrEnum
from registrar.views.utility import StepsHelper from registrar.views.utility import StepsHelper
@ -649,3 +650,64 @@ class DomainApplicationDeleteView(DomainApplicationPermissionDeleteView):
def get_success_url(self): def get_success_url(self):
"""After a delete is successful, redirect to home""" """After a delete is successful, redirect to home"""
return reverse("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 # Let's exclude the approved applications since our
# domain_applications context will be used to populate # domain_applications context will be used to populate
# the active applications table # 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 # Create a placeholder DraftDomain for each incomplete draft
valid_statuses = [DomainApplication.ApplicationStatus.STARTED, DomainApplication.ApplicationStatus.WITHDRAWN] valid_statuses = [DomainApplication.ApplicationStatus.STARTED, DomainApplication.ApplicationStatus.WITHDRAWN]