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)