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

This commit is contained in:
zandercymatics 2024-01-18 15:48:08 -07:00
commit 8082083d63
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
10 changed files with 267 additions and 26 deletions

View file

@ -25,6 +25,22 @@
color: color('primary-darker');
padding-bottom: units(2px);
}
// Ticket #1510
// @include at-media('desktop') {
// th:first-child {
// width: 220px;
// }
// th:nth-child(2) {
// width: 175px;
// }
// th:nth-child(3) {
// width: 130px;
// }
// th:nth-child(5) {
// width: 130px;
// }
// }
}
.dotgov-table {

View file

@ -137,6 +137,11 @@ urlpatterns = [
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
name="invitation-delete",
),
path(
"application/<int:pk>/delete",
views.DomainApplicationDeleteView.as_view(http_method_names=["post"]),
name="application-delete",
),
path(
"domain/<int:pk>/users/<int:user_pk>/delete",
views.DomainDeleteUserView.as_view(http_method_names=["post"]),

View file

@ -487,7 +487,8 @@ class DotGovDomainForm(RegistrarForm):
values = {}
requested_domain = getattr(obj, "requested_domain", None)
if requested_domain is not None:
values["requested_domain"] = Domain.sld(requested_domain.name)
domain_name = requested_domain.name
values["requested_domain"] = Domain.sld(domain_name)
return values
def clean_requested_domain(self):

View file

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

View file

@ -9,7 +9,7 @@
{% if user.is_authenticated %}
{# the entire logged in page goes here #}
<div class="tablet:grid-offset-1 desktop:grid-offset-2">
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
@ -34,7 +34,7 @@
{% endif %}
</p>
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
<section class="section--outlined">
<h2>Domains</h2>
{% if domains %}
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
@ -44,7 +44,17 @@
<th data-sortable scope="col" role="columnheader">Domain name</th>
<th data-sortable scope="col" role="columnheader">Expires</th>
<th data-sortable scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
<th
scope="col"
role="columnheader"
>
<span class="usa-sr-only">Action</span>
</th>
{% comment %}
#1510
{% if has_deletable_applications %}
<th></th>
{% endif %} {% endcomment %}
</tr>
</thead>
<tbody>
@ -97,7 +107,7 @@
{% endif %}
</section>
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
<section class="section--outlined">
<h2>Domain requests</h2>
{% if domain_applications %}
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
@ -108,13 +118,22 @@
<th data-sortable scope="col" role="columnheader">Date submitted</th>
<th data-sortable scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
{% if has_deletable_applications %}
<th scope="col" role="columnheader"><span class="usa-sr-only">Delete Action</span></th>
{% endif %}
</tr>
</thead>
<tbody>
{% for application in domain_applications %}
<tr>
<th th scope="row" role="rowheader" data-label="Domain name">
{{ application.requested_domain.name|default:"New domain request" }}
{% if application.requested_domain is None %}
New domain request
<br>
<span class="text-base font-body-xs">({{ application.created_at }})</span>
{% else %}
{{ application.requested_domain.name }}
{% endif %}
</th>
<td data-sort-value="{{ application.submission_date|date:"U" }}" data-label="Date submitted">
{% if application.submission_date %}
@ -125,22 +144,68 @@
</td>
<td data-label="Status">{{ application.get_status_display }}</td>
<td>
{% if application.status == "started" or application.status == "action needed" or application.status == "withdrawn" %}
{% 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" }} </span>
{% else %}
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}} </span>
{% endif %}
Manage <span class="usa-sr-only">{{ application.requested_domain.name|default:"New domain request ("|add:application.created_at|add:")" }}</span>
{% endif %}
</a>
</td>
{% if has_deletable_applications %}
<td>
{% if application.status == "started" or application.status == "withdrawn" %}
<a
role="button"
id="button-toggle-delete-domain-alert-{{ forloop.counter }}"
href="#toggle-delete-domain-alert-{{ forloop.counter }}"
class="usa-button--unstyled text-no-underline"
aria-controls="toggle-delete-domain-alert-{{ forloop.counter }}"
data-open-modal
>
<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>
</a>
<div
class="usa-modal"
id="toggle-delete-domain-alert-{{ forloop.counter }}"
aria-labelledby="Are you sure you want to continue?"
aria-describedby="Domain will be removed"
data-force-action
>
<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 %}
{% 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 %}
{% 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 %}
{% endwith %}
{% endif %}
</form>
</div>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>

View file

@ -4,6 +4,10 @@
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
{{ modal_heading }}
{% if heading_value is not None %}
<br>
{{ heading_value }}
{% endif %}
</h2>
<div class="usa-prose">
<p id="modal-1-description">

View file

@ -95,11 +95,93 @@ class LoggedInTests(TestWithUser):
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(creator=self.user, requested_domain=site)
response = self.client.get("/")
# count = 2 because it is also in screenreader content
self.assertContains(response, "igorville.gov", count=2)
# count = 5 because of screenreader content
self.assertContains(response, "igorville.gov", count=5)
# clean up
application.delete()
def test_home_deletes_withdrawn_domain_application(self):
"""Tests if the user can delete a DomainApplication in the 'withdrawn' status"""
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user, requested_domain=site, status=DomainApplication.ApplicationStatus.WITHDRAWN
)
# Ensure that igorville.gov exists on the page
home_page = self.client.get("/")
self.assertContains(home_page, "igorville.gov")
# Check if the delete button exists. We can do this by checking for its id and text content.
self.assertContains(home_page, "Delete")
self.assertContains(home_page, "button-toggle-delete-domain-alert-1")
# Trigger the delete logic
response = self.client.post(reverse("application-delete", kwargs={"pk": application.pk}), follow=True)
self.assertNotContains(response, "igorville.gov")
# clean up
application.delete()
def test_home_deletes_started_domain_application(self):
"""Tests if the user can delete a DomainApplication in the 'started' status"""
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user, requested_domain=site, status=DomainApplication.ApplicationStatus.STARTED
)
# Ensure that igorville.gov exists on the page
home_page = self.client.get("/")
self.assertContains(home_page, "igorville.gov")
# Check if the delete button exists. We can do this by checking for its id and text content.
self.assertContains(home_page, "Delete")
self.assertContains(home_page, "button-toggle-delete-domain-alert-1")
# Trigger the delete logic
response = self.client.post(reverse("application-delete", kwargs={"pk": application.pk}), follow=True)
self.assertNotContains(response, "igorville.gov")
# clean up
application.delete()
def test_home_doesnt_delete_other_domain_applications(self):
"""Tests to ensure the user can't delete Applications not in the status of STARTED or WITHDRAWN"""
# Given that we are including a subset of items that can be deleted while excluding the rest,
# subTest is appropriate here as otherwise we would need many duplicate tests for the same reason.
draft_domain = DraftDomain.objects.create(name="igorville.gov")
for status in DomainApplication.ApplicationStatus:
if status not in [
DomainApplication.ApplicationStatus.STARTED,
DomainApplication.ApplicationStatus.WITHDRAWN,
]:
with self.subTest(status=status):
application = DomainApplication.objects.create(
creator=self.user, requested_domain=draft_domain, status=status
)
# Trigger the delete logic
response = self.client.post(
reverse("application-delete", kwargs={"pk": application.pk}), follow=True
)
# Check for a 403 error - the end user should not be allowed to do this
self.assertEqual(response.status_code, 403)
desired_application = DomainApplication.objects.filter(requested_domain=draft_domain)
# Make sure the DomainApplication wasn't deleted
self.assertEqual(desired_application.count(), 1)
# clean up
application.delete()
def test_application_form_view(self):
response = self.client.get("/request/", follow=True)
self.assertContains(

View file

@ -13,6 +13,7 @@ from registrar.models import DomainApplication
from registrar.models.user import User
from registrar.utility import StrEnum
from registrar.views.utility import StepsHelper
from registrar.views.utility.permission_views import DomainApplicationPermissionDeleteView
from .utility import (
DomainApplicationPermissionView,
@ -148,9 +149,7 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
except DomainApplication.DoesNotExist:
logger.debug("Application id %s did not have a DomainApplication" % id)
self._application = DomainApplication.objects.create(
creator=self.request.user, # type: ignore
)
self._application = DomainApplication.objects.create(creator=self.request.user)
self.storage["application_id"] = self._application.id
return self._application
@ -159,7 +158,6 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
def storage(self):
# marking session as modified on every access
# so that updates to nested keys are always saved
# push to sandbox will remove
self.request.session.modified = True
return self.request.session.setdefault(self.prefix, {})
@ -628,3 +626,26 @@ class ApplicationWithdrawn(DomainApplicationPermissionWithdrawView):
application.withdraw()
application.save()
return HttpResponseRedirect(reverse("home"))
class DomainApplicationDeleteView(DomainApplicationPermissionDeleteView):
"""Delete view for home that allows the end user to delete DomainApplications"""
object: DomainApplication # workaround for type mismatch in DeleteView
def has_permission(self):
"""Custom override for has_permission to exclude all statuses, except WITHDRAWN and STARTED"""
has_perm = super().has_permission()
if not has_perm:
return False
status = self.get_object().status
valid_statuses = [DomainApplication.ApplicationStatus.WITHDRAWN, DomainApplication.ApplicationStatus.STARTED]
if status not in valid_statuses:
return False
return True
def get_success_url(self):
"""After a delete is successful, redirect to home"""
return reverse("home")

View file

@ -7,15 +7,53 @@ def index(request):
"""This page is available to anyone without logging in."""
context = {}
if request.user.is_authenticated:
applications = DomainApplication.objects.filter(creator=request.user)
# Let's exclude the approved applications since our
# domain_applications context will be used to populate
# the active applications table
context["domain_applications"] = applications.exclude(status="approved")
# Get all domain applications the user has access to
applications, deletable_applications = _get_applications(request)
user_domain_roles = UserDomainRole.objects.filter(user=request.user)
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
domains = Domain.objects.filter(id__in=domain_ids)
context["domain_applications"] = applications
# Get all domains the user has access to
domains = _get_domains(request)
context["domains"] = domains
# Determine if the user will see applications that they can delete
has_deletable_applications = deletable_applications.exists()
context["has_deletable_applications"] = has_deletable_applications
# If they can delete applications, add the delete button to the context
if has_deletable_applications:
# Add the delete modal button to the context
modal_button = (
'<button type="submit" '
'class="usa-button usa-button--secondary" '
'name="delete-application">Yes, delete request</button>'
)
context["modal_button"] = modal_button
return render(request, "home.html", context)
def _get_applications(request):
"""Given the current request,
get all DomainApplications that are associated with the UserDomainRole object.
Returns a tuple of all applications, and those that are deletable by the user.
"""
# 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")
# Create a placeholder DraftDomain for each incomplete draft
valid_statuses = [DomainApplication.ApplicationStatus.STARTED, DomainApplication.ApplicationStatus.WITHDRAWN]
deletable_applications = applications.filter(status__in=valid_statuses)
return (applications, deletable_applications)
def _get_domains(request):
"""Given the current request,
get all domains that are associated with the UserDomainRole object"""
user_domain_roles = UserDomainRole.objects.filter(user=request.user)
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
return Domain.objects.filter(id__in=domain_ids)

View file

@ -126,6 +126,14 @@ class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteVie
object: DomainInvitation # workaround for type mismatch in DeleteView
class DomainApplicationPermissionDeleteView(DomainApplicationPermission, DeleteView, abc.ABC):
"""Abstract view for deleting a DomainApplication."""
model = DomainApplication
object: DomainApplication
class UserDomainRolePermissionView(UserDomainRolePermission, DetailView, abc.ABC):
"""Abstract base view for UserDomainRole that enforces permissions.