diff --git a/src/registrar/assets/js/dja-collapse.js b/src/registrar/assets/js/dja-collapse.js new file mode 100644 index 000000000..c33954192 --- /dev/null +++ b/src/registrar/assets/js/dja-collapse.js @@ -0,0 +1,45 @@ +/* + * We will run our own version of + * https://github.com/django/django/blob/195d885ca01b14e3ce9a1881c3b8f7074f953736/django/contrib/admin/static/admin/js/collapse.js + * Works with our fieldset override +*/ + +/*global gettext*/ +'use strict'; +{ + window.addEventListener('load', function() { + // Add anchor tag for Show/Hide link + const fieldsets = document.querySelectorAll('fieldset.collapse--dotgov'); + for (const [i, elem] of fieldsets.entries()) { + // Don't hide if fields in this fieldset have errors + if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { + elem.classList.add('collapsed'); + const button = elem.querySelector('button'); + button.id = 'fieldsetcollapser' + i; + button.className = 'collapse-toggle--dotgov usa-button usa-button--unstyled'; + } + } + // Add toggle to hide/show anchor tag + const toggleFuncDotgov = function(e) { + e.preventDefault(); + e.stopPropagation(); + const fieldset = this.closest('fieldset'); + const spanElement = this.querySelector('span'); + const useElement = this.querySelector('use'); + if (fieldset.classList.contains('collapsed')) { + // Show + spanElement.textContent = 'Hide details'; + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); + fieldset.classList.remove('collapsed'); + } else { + // Hide + spanElement.textContent = 'Show details'; + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); + fieldset.classList.add('collapsed'); + } + }; + document.querySelectorAll('.collapse-toggle--dotgov').forEach(function(el) { + el.addEventListener('click', toggleFuncDotgov); + }); + }); +} diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 680c7cdf4..b4b590acb 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -112,12 +112,20 @@ html[data-theme="light"] { .change-list .usa-table--borderless thead th, .change-list .usa-table thead td, .change-list .usa-table thead th, + .change-form .usa-table, + .change-form .usa-table--striped tbody tr:nth-child(odd) td, + .change-form .usa-table--borderless thead th, + .change-form .usa-table thead td, + .change-form .usa-table thead th, body.dashboard, body.change-list, body.change-form, .analytics { color: var(--body-fg); } + .usa-table td { + background-color: transparent; + } } // Firefox needs this to be specifically set @@ -127,11 +135,20 @@ html[data-theme="dark"] { .change-list .usa-table--borderless thead th, .change-list .usa-table thead td, .change-list .usa-table thead th, + .change-form .usa-table, + .change-form .usa-table--striped tbody tr:nth-child(odd) td, + .change-form .usa-table--borderless thead th, + .change-form .usa-table thead td, + .change-form .usa-table thead th, body.dashboard, body.change-list, - body.change-form { + body.change-form, + .analytics { color: var(--body-fg); } + .usa-table td { + background-color: transparent; + } } #branding h1 a:link, #branding h1 a:visited { diff --git a/src/registrar/management/commands/populate_domain_updated_federal_agency.py b/src/registrar/management/commands/populate_domain_updated_federal_agency.py new file mode 100644 index 000000000..dd8ceb3b2 --- /dev/null +++ b/src/registrar/management/commands/populate_domain_updated_federal_agency.py @@ -0,0 +1,121 @@ +"""" +Data migration: Renaming deprecated Federal Agencies to +their new updated names ie (U.S. Peace Corps to Peace Corps) +within Domain Information and Domain Requests +""" + +import logging + +from django.core.management import BaseCommand +from registrar.models import DomainInformation, DomainRequest, FederalAgency +from registrar.management.commands.utility.terminal_helper import ScriptDataHelper + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Transfers Domain Request and Domain Information federal agency field from string to FederalAgency object" + + # Deprecated federal agency names mapped to designated replacements {old_value, new value} + rename_deprecated_federal_agency = { + "Appraisal Subcommittee": "Appraisal Subcommittee of the Federal Financial Institutions Examination Council", + "Barry Goldwater Scholarship and Excellence in Education Program": "Barry Goldwater Scholarship and Excellence in Education Foundation", # noqa + "Federal Reserve System": "Federal Reserve Board of Governors", + "Harry S Truman Scholarship Foundation": "Harry S. Truman Scholarship Foundation", + "Japan-US Friendship Commission": "Japan-U.S. Friendship Commission", + "Japan-United States Friendship Commission": "Japan-U.S. Friendship Commission", + "John F. Kennedy Center for Performing Arts": "John F. Kennedy Center for the Performing Arts", + "Occupational Safety & Health Review Commission": "Occupational Safety and Health Review Commission", + "Corporation for National & Community Service": "Corporation for National and Community Service", + "Export/Import Bank of the U.S.": "Export-Import Bank of the United States", + "Medical Payment Advisory Commission": "Medicare Payment Advisory Commission", + "U.S. Peace Corps": "Peace Corps", + "Chemical Safety Board": "U.S. Chemical Safety Board", + "Nuclear Waste Technical Review Board": "U.S. Nuclear Waste Technical Review Board", + "State, Local, and Tribal Government": "Non-Federal Agency", + # "U.S. China Economic and Security Review Commission": "U.S.-China Economic and Security Review Commission", + } + + def find_federal_agency_row(self, domain_object): + federal_agency = domain_object.federal_agency + # Domain Information objects without a federal agency default to Non-Federal Agency + if (federal_agency is None) or (federal_agency == ""): + federal_agency = "Non-Federal Agency" + if federal_agency in self.rename_deprecated_federal_agency.keys(): + federal_agency = self.rename_deprecated_federal_agency[federal_agency] + return FederalAgency.objects.filter(agency=federal_agency).get() + + def handle(self, **options): + """ + Renames the Federal Agency to the correct new naming + for both Domain Information and Domain Requests objects. + + NOTE: If it's None for a domain request, we skip it as + a user most likely hasn't gotten to it yet. + """ + logger.info("Transferring federal agencies to FederalAgency object") + # DomainInformation object we populate with updated_federal_agency which are then bulk updated + domain_infos_to_update = [] + domain_requests_to_update = [] + # Domain Requests with null federal_agency that are not populated with updated_federal_agency + domain_requests_skipped = [] + domain_infos_with_errors = [] + domain_requests_with_errors = [] + + domain_infos = DomainInformation.objects.all() + domain_requests = DomainRequest.objects.all() + + logger.info(f"Found {len(domain_infos)} DomainInfo objects with federal agency.") + logger.info(f"Found {len(domain_requests)} Domain Request objects with federal agency.") + + for domain_info in domain_infos: + try: + federal_agency_row = self.find_federal_agency_row(domain_info) + domain_info.updated_federal_agency = federal_agency_row + domain_infos_to_update.append(domain_info) + logger.info( + f"DomainInformation {domain_info} => updated_federal_agency set to: \ + {domain_info.updated_federal_agency}" + ) + except Exception as err: + domain_infos_with_errors.append(domain_info) + logger.info( + f"DomainInformation {domain_info} failed to update updated_federal_agency \ + from federal_agency {domain_info.federal_agency}. Error: {err}" + ) + + ScriptDataHelper.bulk_update_fields(DomainInformation, domain_infos_to_update, ["updated_federal_agency"]) + + for domain_request in domain_requests: + try: + if (domain_request.federal_agency is None) or (domain_request.federal_agency == ""): + domain_requests_skipped.append(domain_request) + else: + federal_agency_row = self.find_federal_agency_row(domain_request) + domain_request.updated_federal_agency = federal_agency_row + domain_requests_to_update.append(domain_request) + logger.info( + f"DomainRequest {domain_request} => updated_federal_agency set to: \ + {domain_request.updated_federal_agency}" + ) + except Exception as err: + domain_requests_with_errors.append(domain_request) + logger.info( + f"DomainRequest {domain_request} failed to update updated_federal_agency \ + from federal_agency {domain_request.federal_agency}. Error: {err}" + ) + + ScriptDataHelper.bulk_update_fields(DomainRequest, domain_requests_to_update, ["updated_federal_agency"]) + + logger.info(f"{len(domain_infos_to_update)} DomainInformation rows updated update_federal_agency.") + logger.info( + f"{len(domain_infos_with_errors)} DomainInformation rows errored when updating update_federal_agency. \ + {domain_infos_with_errors}" + ) + logger.info(f"{len(domain_requests_to_update)} DomainRequest rows updated update_federal_agency.") + logger.info(f"{len(domain_requests_skipped)} DomainRequest rows with null federal_agency skipped.") + logger.info( + f"{len(domain_requests_with_errors)} DomainRequest rows errored when updating update_federal_agency. \ + {domain_requests_with_errors}" + ) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 17bd9a100..75fbadc3e 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -16,12 +16,21 @@ from .utility.time_stamped_model import TimeStampedModel from ..utility.email import send_templated_email, EmailSendingError from itertools import chain +from auditlog.models import AuditlogHistoryField # type: ignore + logger = logging.getLogger(__name__) class DomainRequest(TimeStampedModel): """A registrant's domain request for a new domain.""" + # https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history + # If we note any performace degradation due to this addition, + # we can query the auditlogs table in admin.py and add the results to + # extra_context in the change_view method for DomainRequestAdmin. + # This is the more straightforward way so trying it first. + history = AuditlogHistoryField() + # Constants for choice fields class DomainRequestStatus(models.TextChoices): STARTED = "started", "Started" diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 93babb2c4..1cc450e02 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -67,7 +67,35 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endblock field_readonly %} {% block after_help_text %} - {% if field.field.name == "creator" %} + {% if field.field.name == "status" and original_object.history.count > 0 %} +
+ +
+ + + + + + + + + + {% for log_entry in original_object.history.all %} + {% for key, value in log_entry.changes_display_dict.items %} + {% if key == "status" %} + + + + + + {% endif %} + {% endfor %} + {% endfor %} + +
StatusUserChanged at
{{ value.1|default:"None" }}{{ log_entry.actor|default:"None" }}{{ log_entry.timestamp|default:"None" }}
+
+
+ {% elif field.field.name == "creator" %}
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly user_verification_type=original_object.creator.get_verification_type_display%} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 6dd88c1c1..c2f11b85d 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -789,6 +789,7 @@ def create_ready_domain(): return domain +# TODO in 1793: Remove the federal agency/updated federal agency fields def completed_domain_request( has_other_contacts=True, has_current_website=True, @@ -803,6 +804,8 @@ def completed_domain_request( generic_org_type="federal", is_election_board=False, organization_type=None, + federal_agency=None, + updated_federal_agency=None, ): """A completed domain request.""" if not user: @@ -839,6 +842,7 @@ def completed_domain_request( last_name="Bob", is_staff=True, ) + domain_request_kwargs = dict( generic_org_type=generic_org_type, is_election_board=is_election_board, @@ -856,6 +860,8 @@ def completed_domain_request( creator=user, status=status, investigator=investigator, + federal_agency=federal_agency, + updated_federal_agency=updated_federal_agency, ) if has_about_your_organization: domain_request_kwargs["about_your_organization"] = "e-Government" @@ -864,7 +870,6 @@ def completed_domain_request( if organization_type: domain_request_kwargs["organization_type"] = organization_type - domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs) if has_other_contacts: diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index f7fa0a605..59aea4a0c 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -888,6 +888,96 @@ class TestDomainRequestAdmin(MockEppLib): self.test_helper.assert_response_contains_distinct_values(response, expected_values) @less_console_noise_decorator + def test_status_logs(self): + """ + Tests that the status changes are shown in a table on the domain request change form, + accurately and in chronological order. + """ + + # Create a fake domain request and domain + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED) + + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + + # Table will contain one row for Started + self.assertContains(response, "Started", count=1) + self.assertNotContains(response, "Submitted") + + domain_request.submit() + domain_request.save() + + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Table will contain and extra row for Submitted + self.assertContains(response, "Started", count=1) + self.assertContains(response, "Submitted", count=1) + + domain_request.in_review() + domain_request.save() + + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Table will contain and extra row for In review + self.assertContains(response, "Started", count=1) + self.assertContains(response, "Submitted", count=1) + self.assertContains(response, "In review", count=1) + + domain_request.action_needed() + domain_request.save() + + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Table will contain and extra row for Action needed + self.assertContains(response, "Started", count=1) + self.assertContains(response, "Submitted", count=1) + self.assertContains(response, "In review", count=1) + self.assertContains(response, "Action needed", count=1) + + domain_request.in_review() + domain_request.save() + + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Define the expected sequence of status changes + expected_status_changes = [ + "In review", + "Action needed", + "In review", + "Submitted", + "Started", + ] + + # Test for the order of status changes + for status_change in expected_status_changes: + self.assertContains(response, status_change, html=True) + + # Table now contains 2 rows for Approved + self.assertContains(response, "Started", count=1) + self.assertContains(response, "Submitted", count=1) + self.assertContains(response, "In review", count=2) + self.assertContains(response, "Action needed", count=1) + def test_collaspe_toggle_button_markup(self): """ Tests for the correct collapse toggle button markup @@ -906,7 +996,6 @@ class TestDomainRequestAdmin(MockEppLib): # Make sure the page loaded, and that we're on the right page self.assertEqual(response.status_code, 200) self.assertContains(response, domain_request.requested_domain.name) - self.test_helper.assertContains(response, "Show details") @less_console_noise_decorator diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py index 617e305a1..86ea1847f 100644 --- a/src/registrar/tests/test_management_scripts.py +++ b/src/registrar/tests/test_management_scripts.py @@ -841,3 +841,120 @@ class TestDiscloseEmails(MockEppLib): ) ] ) + + +# TODO in #1793: Remove this whole test class +class TestPopulateDomainUpdatedFederalAgency(TestCase): + def setUp(self): + super().setUp() + + # Get the domain requests + self.domain_request_1 = completed_domain_request( + name="stitches.gov", + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + is_election_board=True, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + federal_agency="U.S. Peace Corps", + ) + self.domain_request_2 = completed_domain_request( + name="fadoesntexist.gov", + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + is_election_board=True, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + federal_agency="MEOWARDRULES", + ) + self.domain_request_3 = completed_domain_request( + name="nullfederalagency.gov", + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + is_election_board=True, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + federal_agency=None, + ) + + # Approve all three requests + self.domain_request_1.approve() + self.domain_request_2.approve() + self.domain_request_3.approve() + + # Get the domains + self.domain_1 = Domain.objects.get(name="stitches.gov") + self.domain_2 = Domain.objects.get(name="fadoesntexist.gov") + self.domain_3 = Domain.objects.get(name="nullfederalagency.gov") + + # Get the domain infos + self.domain_info_1 = DomainInformation.objects.get(domain=self.domain_1) + self.domain_info_2 = DomainInformation.objects.get(domain=self.domain_2) + self.domain_info_3 = DomainInformation.objects.get(domain=self.domain_3) + + def tearDown(self): + super().tearDown() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Domain.objects.all().delete() + + def run_populate_domain_updated_federal_agency(self): + """ + This method executes the populate_domain_updated_federal_agency command. + + The 'call_command' function from Django's management framework is then used to + execute the populate_domain_updated_federal_agency command. + """ + with less_console_noise(): + call_command("populate_domain_updated_federal_agency") + + def test_domain_information_renaming_federal_agency_success(self): + """ + Domain Information updates successfully for an "outdated" Federal Agency + """ + + self.run_populate_domain_updated_federal_agency() + + self.domain_info_1.refresh_from_db() + + previous_federal_agency_name = self.domain_info_1.federal_agency + + updated_federal_agency_name = self.domain_info_1.updated_federal_agency.agency + + self.assertEqual(previous_federal_agency_name, "U.S. Peace Corps") + self.assertEqual(updated_federal_agency_name, "Peace Corps") + + def test_domain_information_does_not_exist(self): + """ + Update a Federal Agency that doesn't exist + (should return None bc the Federal Agency didn't exist before) + """ + + self.run_populate_domain_updated_federal_agency() + + self.domain_info_2.refresh_from_db() + + self.assertEqual(self.domain_info_2.updated_federal_agency, None) + + def test_domain_request_is_skipped(self): + """ + Update a Domain Request that doesn't exist + (should return None bc the Federal Agency didn't exist before) + """ + + # Test case #2 + self.run_populate_domain_updated_federal_agency() + + self.domain_request_2.refresh_from_db() + + self.assertEqual(self.domain_request_2.updated_federal_agency, None) + + def test_domain_information_updating_null_federal_agency_to_non_federal_agency(self): + """ + Updating a Domain Information that was previously None + to Non-Federal Agency + """ + + self.run_populate_domain_updated_federal_agency() + + self.domain_info_3.refresh_from_db() + + previous_federal_agency_name = self.domain_info_3.federal_agency + updated_federal_agency_name = self.domain_info_3.updated_federal_agency.agency + + self.assertEqual(previous_federal_agency_name, None) + self.assertEqual(updated_federal_agency_name, "Non-Federal Agency")