Repository: cisagov/manage.get.gov

Issue:
Implement basic reporting functionality for MVP (besides Domain Growth report)
### Story

As an Admin, I want to quickly export domain data reports directly from the domains page (/admin/registrar/domain/) so that I can easily access and analyze the domain data.

### Acceptance Criteria

- [ ] Three reports are available to download on the domains page:
  - [ ] [Domains by type](https://docs.google.com/spreadsheets/d/1_nMU2obW22U6NlOSC2ARxf3PpsJnSe2wMo5AyLSzXzk/edit?usp=sharing) (sorted by domain name)
  - [ ] [current-full.csv](https://github.com/cisagov/dotgov-data/blob/main/current-full.csv) (sorted by domain name, then agency, then domain type)
  - [ ] [current-federal.csv](https://github.com/cisagov/dotgov-data/blob/main/current-federal.csv) (sorted by domain name, then agency, then domain type)
- [ ] Each CSV report should contain accurate and up-to-date domain data from the database, sorted in the ways they are in the examples above.
- [ ] Single dropdown with the three report options which the user can select
- [ ] Clicking on each report triggers an immediate download of the relevant CSV report
- [ ] The UI components should be consistent with the existing design language of the admin portal.

### Additional Context

- This feature is a stop-gap measure, meant to provide immediate access to crucial reports while the ideal report interface is being developed. Future work is at #997.

- Security email may be pulled from .gov database rather than thru EPP call to registry.

### Issue Links

🔄 Relates to: #938 #143 #1075 


Discussion:
Here's a sketch of how this could look:
![New Drawing 15](https://github.com/cisagov/getgov/assets/13547334/5180aaf3-b926-46a8-bdc5-e48590683106)

Excellent! The only thing this is missing is the publication of current-federal.csv and current-full.csv at a public URL. Added at #1075.

I've re-added the refinement label so the product leads to ensure the A/C are clear.

Resolving patch:
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
--- 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 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 @@ def organization_type(self, obj):
     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/utility/csv_export.py b/src/registrar/utility/csv_export.py
new file mode 100644
--- /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)


Test patch:
diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py
new file mode 100644
--- /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
--- a/src/registrar/tests/test_views.py
+++ b/src/registrar/tests/test_views.py
@@ -1170,6 +1170,7 @@ def tearDown(self):
             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()