diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 904ce66a4..8914e5c87 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1,5 +1,6 @@ import logging from django import forms +from django.http import HttpResponse from django_fsm import get_available_FIELD_transitions from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -10,6 +11,7 @@ from django.urls import reverse from epplibwrapper.errors import ErrorCode, RegistryError from registrar.models.domain import Domain from registrar.models.utility.admin_sort_fields import AdminSortFields +from registrar.utility import csv_export from . import models from auditlog.models import LogEntry # type: ignore from auditlog.admin import LogEntryAdmin # type: ignore @@ -747,8 +749,59 @@ class DomainAdmin(ListHeaderAdmin): search_fields = ["name"] search_help_text = "Search by domain name." change_form_template = "django/admin/domain_change_form.html" + change_list_template = "django/admin/domain_change_list.html" readonly_fields = ["state"] + def export_data_type(self, request): + # match the CSV example with all the fields + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="domains-by-type.csv"' + csv_export.export_data_type_to_csv(response) + return response + + def export_data_full(self, request): + # Smaller export based on 1 + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="current-full.csv"' + csv_export.export_data_full_to_csv(response) + return response + + def export_data_federal(self, request): + # Federal only + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="current-federal.csv"' + csv_export.export_data_federal_to_csv(response) + return response + + def get_urls(self): + from django.urls import path + + urlpatterns = super().get_urls() + + # Used to extrapolate a path name, for instance + # name="{app_label}_{model_name}_export_data_type" + info = self.model._meta.app_label, self.model._meta.model_name + + my_url = [ + path( + "export_data_type/", + self.export_data_type, + name="%s_%s_export_data_type" % info, + ), + path( + "export_data_full/", + self.export_data_full, + name="%s_%s_export_data_full" % info, + ), + path( + "export_data_federal/", + self.export_data_federal, + name="%s_%s_export_data_federal" % info, + ), + ] + + return my_url + urlpatterns + def response_change(self, request, obj): # Create dictionary of action functions ACTION_FUNCTIONS = { diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 35d089cbd..68ff51597 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -180,3 +180,48 @@ h1, h2, h3 { background: var(--primary); color: var(--header-link-color); } + +// Font mismatch issue due to conflicts between django and uswds, +// rough overrides for consistency and readability. May want to revise +// in the future +.object-tools li a, +.object-tools p a { + font-family: "Source Sans Pro Web", "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif; + text-transform: capitalize!important; + font-size: 14px!important; +} + +// For consistency, make the overrided p a +// object tool buttons the same size as the ul li a +.object-tools p { + line-height: 1.25rem; +} + +// Fix margins in mobile view +@media (max-width: 767px) { + .object-tools li { + // our CSS is read before django's, so need !important + // to override + margin-left: 0!important; + margin-right: 15px; + } +} + +// Fix height of buttons +.object-tools li { + height: auto; +} + +// Fixing height of buttons breaks layout because +// object-tools and changelist are siblings with +// flexbox positioning +#changelist { + clear: both; +} + +// Account for the h2, roughly 90px +@include at-media(tablet) { + .object-tools { + padding-left: 90px; + } +} diff --git a/src/registrar/templates/django/admin/domain_change_list.html b/src/registrar/templates/django/admin/domain_change_list.html new file mode 100644 index 000000000..68fdbe7aa --- /dev/null +++ b/src/registrar/templates/django/admin/domain_change_list.html @@ -0,0 +1,23 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools %} + + +{% endblock %} \ No newline at end of file diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py new file mode 100644 index 000000000..404ed358c --- /dev/null +++ b/src/registrar/tests/test_reports.py @@ -0,0 +1,195 @@ +from django.test import TestCase +from io import StringIO +import csv +from registrar.models.domain_information import DomainInformation +from registrar.models.domain import Domain +from registrar.models.user import User +from django.contrib.auth import get_user_model +from registrar.utility.csv_export import export_domains_to_writer + + +class ExportDataTest(TestCase): + def setUp(self): + username = "test_user" + first_name = "First" + last_name = "Last" + email = "info@example.com" + self.user = get_user_model().objects.create( + username=username, first_name=first_name, last_name=last_name, email=email + ) + + self.domain_1, _ = Domain.objects.get_or_create( + name="cdomain1.gov", state=Domain.State.READY + ) + self.domain_2, _ = Domain.objects.get_or_create( + name="adomain2.gov", state=Domain.State.DNS_NEEDED + ) + self.domain_3, _ = Domain.objects.get_or_create( + name="ddomain3.gov", state=Domain.State.ON_HOLD + ) + self.domain_4, _ = Domain.objects.get_or_create( + name="bdomain4.gov", state=Domain.State.UNKNOWN + ) + self.domain_4, _ = Domain.objects.get_or_create( + name="bdomain4.gov", state=Domain.State.UNKNOWN + ) + + self.domain_information_1, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_1, + organization_type="federal", + federal_agency="World War I Centennial Commission", + federal_type="executive", + ) + self.domain_information_2, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_2, + organization_type="interstate", + ) + self.domain_information_3, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_3, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + ) + self.domain_information_4, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_4, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + ) + + def tearDown(self): + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + User.objects.all().delete() + super().tearDown() + + def test_export_domains_to_writer(self): + """Test that export_domains_to_writer returns the + existing domain, test that sort by domain name works, + test that filter works""" + # Create a CSV file in memory + csv_file = StringIO() + writer = csv.writer(csv_file) + + # Define columns, sort fields, and filter condition + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "AO", + "AO email", + "Submitter", + "Submitter title", + "Submitter email", + "Submitter phone", + "Security Contact Email", + "Status", + ] + sort_fields = ["domain__name"] + filter_condition = { + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + + # Call the export function + export_domains_to_writer(writer, columns, sort_fields, filter_condition) + + # Reset the CSV file's position to the beginning + csv_file.seek(0) + + # Read the content into a variable + csv_content = csv_file.read() + + # We expect READY domains, + # sorted alphabetially by domain name + expected_content = ( + "Domain name,Domain type,Agency,Organization name,City,State,AO," + "AO email,Submitter,Submitter title,Submitter email,Submitter phone," + "Security Contact Email,Status\n" + "adomain2.gov,Interstate,dnsneeded\n" + "cdomain1.gov,Federal - Executive,World War I Centennial Commission,ready\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,onhold\n" + ) + + # Normalize line endings and remove commas, + # spaces and leading/trailing whitespace + csv_content = ( + csv_content.replace(",,", "") + .replace(",", "") + .replace(" ", "") + .replace("\r\n", "\n") + .strip() + ) + expected_content = ( + expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + ) + + self.assertEqual(csv_content, expected_content) + + def test_export_domains_to_writer_additional(self): + """An additional test for filters and multi-column sort""" + # Create a CSV file in memory + csv_file = StringIO() + writer = csv.writer(csv_file) + + # Define columns, sort fields, and filter condition + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "Security Contact Email", + ] + sort_fields = ["domain__name", "federal_agency", "organization_type"] + filter_condition = { + "organization_type__icontains": "federal", + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + + # Call the export function + export_domains_to_writer(writer, columns, sort_fields, filter_condition) + + # Reset the CSV file's position to the beginning + csv_file.seek(0) + + # Read the content into a variable + csv_content = csv_file.read() + + # We expect READY domains, + # federal only + # sorted alphabetially by domain name + expected_content = ( + "Domain name,Domain type,Agency,Organization name,City," + "State,Security Contact Email\n" + "cdomain1.gov,Federal - Executive,World War I Centennial Commission\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home\n" + ) + + # Normalize line endings and remove commas, + # spaces and leading/trailing whitespace + csv_content = ( + csv_content.replace(",,", "") + .replace(",", "") + .replace(" ", "") + .replace("\r\n", "\n") + .strip() + ) + expected_content = ( + expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + ) + + self.assertEqual(csv_content, expected_content) diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 02f19cff0..92fd5af44 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1170,6 +1170,7 @@ class TestWithDomainPermissions(TestWithUser): if hasattr(self.domain, "contacts"): self.domain.contacts.all().delete() DomainApplication.objects.all().delete() + DomainInformation.objects.all().delete() PublicContact.objects.all().delete() Domain.objects.all().delete() UserDomainRole.objects.all().delete() diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py new file mode 100644 index 000000000..ffada0a0b --- /dev/null +++ b/src/registrar/utility/csv_export.py @@ -0,0 +1,119 @@ +import csv +from registrar.models.domain import Domain +from registrar.models.domain_information import DomainInformation +from registrar.models.public_contact import PublicContact + + +def export_domains_to_writer(writer, columns, sort_fields, filter_condition): + # write columns headers to writer + writer.writerow(columns) + + domainInfos = DomainInformation.objects.filter(**filter_condition).order_by( + *sort_fields + ) + for domainInfo in domainInfos: + security_contacts = domainInfo.domain.contacts.filter( + contact_type=PublicContact.ContactTypeChoices.SECURITY + ) + + # create a dictionary of fields which can be included in output + FIELDS = { + "Domain name": domainInfo.domain.name, + "Domain type": domainInfo.get_organization_type_display() + + " - " + + domainInfo.get_federal_type_display() + if domainInfo.federal_type + else domainInfo.get_organization_type_display(), + "Agency": domainInfo.federal_agency, + "Organization name": domainInfo.organization_name, + "City": domainInfo.city, + "State": domainInfo.state_territory, + "AO": domainInfo.authorizing_official.first_name + + " " + + domainInfo.authorizing_official.last_name + if domainInfo.authorizing_official + else " ", + "AO email": domainInfo.authorizing_official.email + if domainInfo.authorizing_official + else " ", + "Security Contact Email": security_contacts[0].email + if security_contacts + else " ", + "Status": domainInfo.domain.state, + "Expiration Date": domainInfo.domain.expiration_date, + } + writer.writerow([FIELDS.get(column, "") for column in columns]) + + +def export_data_type_to_csv(csv_file): + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "AO", + "AO email", + "Security Contact Email", + "Status", + "Expiration Date", + ] + sort_fields = ["domain__name"] + filter_condition = { + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + export_domains_to_writer(writer, columns, sort_fields, filter_condition) + + +def export_data_full_to_csv(csv_file): + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "Security Contact Email", + ] + sort_fields = ["domain__name", "federal_agency", "organization_type"] + filter_condition = { + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + export_domains_to_writer(writer, columns, sort_fields, filter_condition) + + +def export_data_federal_to_csv(csv_file): + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "Security Contact Email", + ] + sort_fields = ["domain__name", "federal_agency", "organization_type"] + filter_condition = { + "organization_type__icontains": "federal", + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + export_domains_to_writer(writer, columns, sort_fields, filter_condition)