merge main

This commit is contained in:
Rachid Mrad 2024-03-18 12:05:29 -04:00
commit 4336f85254
No known key found for this signature in database
9 changed files with 577 additions and 23 deletions

View file

@ -994,6 +994,8 @@ class DomainRequestAdmin(ListHeaderAdmin):
if self.value() == "0":
return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
change_form_template = "django/admin/domain_application_change_form.html"
# Columns
list_display = [
"requested_domain",
@ -1467,6 +1469,20 @@ class DomainAdmin(ListHeaderAdmin):
# Table ordering
ordering = ["name"]
# Override for the delete confirmation page on the domain table (bulk delete action)
delete_selected_confirmation_template = "django/admin/domain_delete_selected_confirmation.html"
def delete_view(self, request, object_id, extra_context=None):
"""
Custom delete_view to perform additional actions or customize the template.
"""
# Set the delete template to a custom one
self.delete_confirmation_template = "django/admin/domain_delete_confirmation.html"
response = super().delete_view(request, object_id, extra_context=extra_context)
return response
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
"""Custom changeform implementation to pass in context information"""
if extra_context is None:
@ -1757,9 +1773,6 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
list_display = ("email", "requestor", "truncated_notes", "created_at")
search_fields = ["email"]
search_help_text = "Search by email."
list_filter = [
"requestor",
]
readonly_fields = [
"requestor",
]

View file

@ -29,20 +29,26 @@ function openInNewTab(el, removeAttribute = false){
*/
(function (){
function createPhantomModalFormButtons(){
let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"]');
let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"].dja-form-placeholder');
form = document.querySelector("form")
submitButtons.forEach((button) => {
let input = document.createElement("input");
input.type = "submit";
if(button.name){
input.name = button.name;
}
if(button.value){
input.value = button.value;
}
input.style.display = "none"
// Add the hidden input to the form
form.appendChild(input);
button.addEventListener("click", () => {
console.log("clicking")
input.click();
})
})
@ -50,6 +56,61 @@ function openInNewTab(el, removeAttribute = false){
createPhantomModalFormButtons();
})();
/** An IIFE for DomainRequest to hook a modal to a dropdown option.
* This intentionally does not interact with createPhantomModalFormButtons()
*/
(function (){
function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){
// If these exist all at the same time, we're on the right page
if (linkClickedDisplaysModal && statusDropdown && statusDropdown.value){
// Set the previous value in the event the user cancels.
let previousValue = statusDropdown.value;
if (actionButton){
// Otherwise, if the confirmation buttion is pressed, set it to that
actionButton.addEventListener('click', function() {
// Revert the dropdown to its previous value
statusDropdown.value = valueToCheck;
});
}else {
console.log("displayModalOnDropdownClick() -> Cancel button was null")
}
// Add a change event listener to the dropdown.
statusDropdown.addEventListener('change', function() {
// Check if "Ineligible" is selected
if (this.value && this.value.toLowerCase() === valueToCheck) {
// Set the old value in the event the user cancels,
// or otherwise exists the dropdown.
statusDropdown.value = previousValue
// Display the modal.
linkClickedDisplaysModal.click()
}
});
}
}
// When the status dropdown is clicked and is set to "ineligible", toggle a confirmation dropdown.
function hookModalToIneligibleStatus(){
// Grab the invisible element that will hook to the modal.
// This doesn't technically need to be done with one, but this is simpler to manage.
let modalButton = document.getElementById("invisible-ineligible-modal-toggler")
let statusDropdown = document.getElementById("id_status")
// Because the modal button does not have the class "dja-form-placeholder",
// it will not be affected by the createPhantomModalFormButtons() function.
let actionButton = document.querySelector('button[name="_set_domain_request_ineligible"]');
let valueToCheck = "ineligible"
displayModalOnDropdownClick(modalButton, statusDropdown, actionButton, valueToCheck);
}
hookModalToIneligibleStatus()
})();
/** An IIFE for pages in DjangoAdmin which may need custom JS implementation.
* Currently only appends target="_blank" to the domain_form object,
* but this can be expanded.

View file

@ -332,4 +332,15 @@ input.admin-confirm-button {
border: solid 1px var(--darkened-bg);
background: var(--darkened-bg);
}
.django-admin-modal .usa-prose ul > li {
list-style-type: inherit;
// Styling based off of the <p> styling in django admin
line-height: 1.5;
margin-bottom: 0;
margin-top: 0;
max-width: 68ex;
}
.usa-summary-box__dhs-color {
color: $dhs-blue-70;
}

View file

@ -0,0 +1,96 @@
{% extends 'admin/change_form.html' %}
{% load i18n static %}
{% block field_sets %}
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #}
<a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>
{{ block.super }}
{% endblock %}
{% block submit_buttons_bottom %}
{% comment %}
Modals behave very weirdly in django admin.
They tend to "strip out" any injected form elements, leaving only the main form.
In addition, USWDS handles modals by first destroying the element, then repopulating it toward the end of the page.
In effect, this means that the modal is not, and cannot, be surrounded by any form element at compile time.
The current workaround for this is to use javascript to inject a hidden input, and bind submit of that
element to the click of the confirmation button within this modal.
This is controlled by the class `dja-form-placeholder` on the button.
In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions
of the application, so this means that it will briefly "populate", causing unintended visual effects.
{% endcomment %}
{# Create a modal for when a domain is marked as ineligible #}
<div
class="usa-modal"
id="toggle-set-ineligible"
aria-labelledby="Are you sure you want to select ineligible status?"
aria-describedby="This request will be marked as ineligible."
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to select ineligible status?
</h2>
<div class="usa-prose">
<p>
When a domain request is in ineligible status, the registrant's permissions within the registrar are restricted as follows:
</p>
<ul>
<li class="font-body-sm">They cannot edit the ineligible request or any other pending requests.</li>
<li class="font-body-sm">They cannot manage any of their approved domains.</li>
<li class="font-body-sm">They cannot initiate a new domain request.</li>
</ul>
<p>
The restrictions will not take effect until you “save” the changes for this domain request.
This action can be reversed, if needed.
</p>
<p>
Domain: <b>{{ original.requested_domain.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New status: <b>{{ original.DomainRequestStatus.INELIGIBLE|capfirst }}</b>
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button"
name="_set_domain_request_ineligible"
data-close-modal
>
Yes, select ineligible status
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
name="_cancel_domain_request_ineligible"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -11,18 +11,15 @@
</div>
<div class="desktop:flex-align-self-end">
{% if original.state != original.State.DELETED %}
<a
class="text-middle"
href="#toggle-extend-expiration-alert"
aria-controls="toggle-extend-expiration-alert"
data-open-modal
>
<a class="text-middle" href="#toggle-extend-expiration-alert" aria-controls="toggle-extend-expiration-alert" data-open-modal>
Extend expiration date
</a>
<span class="margin-left-05 margin-right-05 text-middle"> | </span>
{% endif %}
{% if original.state == original.State.READY %}
<input type="submit" value="Place hold" name="_place_client_hold" class="custom-link-button">
<a class="text-middle" href="#toggle-place-on-hold" aria-controls="toggle-place-on-hold" data-open-modal>
Place hold
</a>
{% elif original.state == original.State.ON_HOLD %}
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button">
{% endif %}
@ -30,7 +27,9 @@
<span class="margin-left-05 margin-right-05 text-middle"> | </span>
{% endif %}
{% if original.state != original.State.DELETED %}
<input type="submit" value="Remove from registry" name="_delete_domain" class="custom-link-button">
<a class="text-middle" href="#toggle-remove-from-registry" aria-controls="toggle-remove-from-registry" data-open-modal>
Remove from registry
</a>
{% endif %}
</div>
</div>
@ -52,8 +51,10 @@
In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions
of the application, so this means that it will briefly "populate", causing unintended visual effects.
{% endcomment %}
{# Create a modal for the _extend_expiration_date button #}
<div
class="usa-modal"
class="usa-modal django-admin-modal"
id="toggle-extend-expiration-alert"
aria-labelledby="Are you sure you want to extend the expiration date?"
aria-describedby="This expiration date will be extended."
@ -114,5 +115,140 @@
</button>
</div>
</div>
{# Create a modal for the _on_hold button #}
<div
class="usa-modal django-admin-modal"
id="toggle-place-on-hold"
aria-labelledby="Are you sure you want to place this domain on hold?"
aria-describedby="This domain will be put on hold"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to place this domain on hold?
</h2>
<div class="usa-prose">
<p>
When a domain is on hold:
</p>
<ul>
<li class="font-body-sm">The domain and its subdomains wont resolve in DNS. Any infrastructure (like websites) will go offline.</li>
<li class="font-body-sm">The domain will still appear in the registrar / admin.</li>
<li class="font-body-sm">Domain managers wont be able to edit the domain.</li>
</ul>
<p>
This action can be reversed, if needed.
</p>
<p>
Domain: <b>{{ original.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New status: <b>{{ original.State.ON_HOLD|capfirst }}</b>
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button dja-form-placeholder"
name="_place_client_hold"
>
Yes, place hold
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{# Create a modal for the _remove_domain button #}
<div
class="usa-modal django-admin-modal"
id="toggle-remove-from-registry"
aria-labelledby="Are you sure you want to remove this domain from the registry?"
aria-describedby="This domain will be removed."
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to remove this domain from the registry?
</h2>
<div class="usa-prose">
<p>
When a domain is removed from the registry:
</p>
<ul>
<li class="font-body-sm">The domain and its subdomains wont resolve in DNS. Any infrastructure (like websites) will go offline.</li>
<li class="font-body-sm">The domain will still appear in the registrar / admin.</li>
<li class="font-body-sm">Domain managers wont be able to edit the domain.</li>
</ul>
<p>
This action cannot be undone.
</p>
<p>
Domain: <b>{{ original.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New status: <b>{{ original.State.DELETED|capfirst }}</b>
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button dja-form-placeholder"
name="_delete_domain"
>
Yes, remove from registry
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends 'admin/delete_confirmation.html' %}
{% load i18n static %}
{% block content %}
<div
class="usa-summary-box width-tablet"
role="region"
aria-labelledby="summary-box-description"
>
<div class="usa-summary-box__body">
<h3 class="usa-summary-box__heading usa-summary-box__dhs-color" id="summary-box-description">
When a domain is deleted:
</h3>
<div class="usa-summary-box__text">
<ul class="usa-list">
<li>The domain will no longer appear in the registrar / admin.</li>
<li>It will be removed from the registry. </li>
<li>The domain and its subdomains wont resolve in DNS.</li>
<li>Any infrastructure (like websites) will go offline.</li>
</ul>
<p>You should probably remove this domain from the registry instead of deleting it.</p>
<p><strong>This action cannot be undone.</strong></p>
</div>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends 'admin/delete_selected_confirmation.html' %}
{% load i18n static %}
{% block content %}
<div
class="usa-summary-box width-tablet"
role="region"
aria-labelledby="summary-box-description"
>
<div class="usa-summary-box__body">
<h3 class="usa-summary-box__heading usa-summary-box__dhs-color" id="summary-box-description">
When a domain is deleted:
</h3>
<div class="usa-summary-box__text">
<ul class="usa-list">
<li>The domain will no longer appear in the registrar / admin.</li>
<li>It will be removed from the registry. </li>
<li>The domain and its subdomains wont resolve in DNS.</li>
<li>Any infrastructure (like websites) will go offline.</li>
</ul>
<p>You should probably remove these domains from the registry instead.</p>
<p><strong>This action cannot be undone.</strong></p>
</div>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -99,7 +99,7 @@ def less_console_noise(output_stream=None):
class GenericTestHelper(TestCase):
"""A helper class that contains various helper functions for TestCases"""
def __init__(self, admin, model=None, url=None, user=None, factory=None, **kwargs):
def __init__(self, admin, model=None, url=None, user=None, factory=None, client=None, **kwargs):
"""
Parameters:
admin (ModelAdmin): The Django ModelAdmin instance associated with the model.
@ -114,6 +114,7 @@ class GenericTestHelper(TestCase):
self.admin = admin
self.model = model
self.url = url
self.client = client
def assert_table_sorted(self, o_index, sort_fields):
"""
@ -149,9 +150,7 @@ class GenericTestHelper(TestCase):
dummy_request.user = self.user
# Mock a user request
middleware = SessionMiddleware(lambda req: req)
middleware.process_request(dummy_request)
dummy_request.session.save()
dummy_request = self._mock_user_request_for_factory(dummy_request)
expected_sort_order = list(self.model.objects.order_by(*sort_fields))
@ -162,6 +161,27 @@ class GenericTestHelper(TestCase):
self.assertEqual(expected_sort_order, returned_sort_order)
def _mock_user_request_for_factory(self, request):
"""Adds sessionmiddleware when using factory to associate session information"""
middleware = SessionMiddleware(lambda req: req)
middleware.process_request(request)
request.session.save()
return request
def get_table_delete_confirmation_page(self, selected_across: str, index: str):
"""
Grabs the response for the delete confirmation page (generated from the actions toolbar).
selected_across and index must both be numbers encoded as str, e.g. "0" rather than 0
"""
response = self.client.post(
self.url,
{"action": "delete_selected", "select_across": selected_across, "index": index, "_selected_action": "23"},
follow=True,
)
print(f"what is the response? {response}")
return response
class MockUserLogin:
def __init__(self, get_response):

View file

@ -61,6 +61,16 @@ class TestDomainAdmin(MockEppLib, WebTest):
self.factory = RequestFactory()
self.app.set_user(self.superuser.username)
self.client.force_login(self.superuser)
# Contains some test tools
self.test_helper = GenericTestHelper(
factory=self.factory,
user=self.superuser,
admin=self.admin,
url=reverse("admin:registrar_domain_changelist"),
model=Domain,
client=self.client,
)
super().setUp()
@skip("TODO for another ticket. This test case is grabbing old db data.")
@ -230,6 +240,35 @@ class TestDomainAdmin(MockEppLib, WebTest):
)
mock_add_message.assert_has_calls([expected_call], 1)
def test_custom_delete_confirmation_page(self):
"""Tests if we override the delete confirmation page for custom content"""
# Create a ready domain with a preset expiration date
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
domain_change_page = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
self.assertContains(domain_change_page, "fake.gov")
# click the "Manage" link
confirmation_page = domain_change_page.click("Delete", index=0)
content_slice = "When a domain is deleted:"
self.assertContains(confirmation_page, content_slice)
def test_custom_delete_confirmation_page_table(self):
"""Tests if we override the delete confirmation page for custom content on the table"""
# Create a ready domain
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
# Get the index. The post expects the index to be encoded as a string
index = f"{domain.id}"
# Simulate selecting a single record, then clicking "Delete selected domains"
response = self.test_helper.get_table_delete_confirmation_page("0", index)
# Check that our content exists
content_slice = "When a domain is deleted:"
self.assertContains(response, content_slice)
def test_short_org_name_in_domains_list(self):
"""
Make sure the short name is displaying in admin on the list page
@ -309,6 +348,17 @@ class TestDomainAdmin(MockEppLib, WebTest):
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Remove from registry")
# The contents of the modal should exist before and after the post.
# Check for the header
self.assertContains(response, "Are you sure you want to remove this domain from the registry?")
# Check for some of its body
self.assertContains(response, "When a domain is removed from the registry:")
# Check for some of the button content
self.assertContains(response, "Yes, remove from registry")
# Test the info dialog
request = self.factory.post(
"/admin/registrar/domain/{}/change/".format(domain.pk),
@ -325,8 +375,60 @@ class TestDomainAdmin(MockEppLib, WebTest):
extra_tags="",
fail_silently=False,
)
# The modal should still exist
self.assertContains(response, "Are you sure you want to remove this domain from the registry?")
self.assertContains(response, "When a domain is removed from the registry:")
self.assertContains(response, "Yes, remove from registry")
self.assertEqual(domain.state, Domain.State.DELETED)
def test_on_hold_is_successful_web_test(self):
"""
Scenario: Domain on_hold is successful through webtest
"""
with less_console_noise():
domain = create_ready_domain()
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
# Check the contents of the modal
# Check for the header
self.assertContains(response, "Are you sure you want to place this domain on hold?")
# Check for some of its body
self.assertContains(response, "When a domain is on hold:")
# Check for some of the button content
self.assertContains(response, "Yes, place hold")
# Grab the form to submit
form = response.forms["domain_form"]
# Submit the form
response = form.submit("_place_client_hold")
# Follow the response
response = response.follow()
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Remove hold")
# The modal should still exist
# Check for the header
self.assertContains(response, "Are you sure you want to place this domain on hold?")
# Check for some of its body
self.assertContains(response, "When a domain is on hold:")
# Check for some of the button content
self.assertContains(response, "Yes, place hold")
# Web test has issues grabbing up to date data from the db, so we can test
# the returned view instead
self.assertContains(response, '<div class="readonly">On hold</div>')
def test_deletion_ready_fsm_failure(self):
"""
Scenario: Domain deletion is unsuccessful
@ -1101,7 +1203,9 @@ class TestDomainRequestAdmin(MockEppLib):
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
# Create a mock request
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
request = self.factory.post(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), follow=True
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Modify the domain request's property
@ -1113,6 +1217,64 @@ class TestDomainRequestAdmin(MockEppLib):
# Test that approved domain exists and equals requested domain
self.assertEqual(domain_request.creator.status, "restricted")
def test_user_sets_restricted_status_modal(self):
"""Tests the modal for when a user sets the status to restricted"""
with less_console_noise():
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
# Create a sample domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
# Check that the modal has the right content
# Check for the header
self.assertContains(response, "Are you sure you want to select ineligible status?")
# Check for some of its body
self.assertContains(response, "When a domain request is in ineligible status")
# Check for some of the button content
self.assertContains(response, "Yes, select ineligible status")
# Create a mock request
request = self.factory.post(
"/admin/registrar/domainrequest{}/change/".format(domain_request.pk), follow=True
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Modify the domain request's property
domain_request.status = DomainRequest.DomainRequestStatus.INELIGIBLE
# Use the model admin's save_model method
self.admin.save_model(request, domain_request, form=None, change=True)
# Test that approved domain exists and equals requested domain
self.assertEqual(domain_request.creator.status, "restricted")
# 'Get' to the domain request again
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
# The modal should be unchanged
self.assertContains(response, "Are you sure you want to select ineligible status?")
self.assertContains(response, "When a domain request is in ineligible status")
self.assertContains(response, "Yes, select ineligible status")
def test_readonly_when_restricted_creator(self):
with less_console_noise():
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)