mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-15 05:54:11 +02:00
Merge branch 'za/1501-users-delete-domain-records' into za/1484-domain-manager-delete
This commit is contained in:
commit
99ee850238
7 changed files with 250 additions and 37 deletions
|
@ -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(
|
||||||
|
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue