Repository: cisagov/manage.get.gov

Issue:
Add "First ready on" to "all domain metadata" report
### Issue description

To support program purposes, let's add the date that a domain was created to our "all domain metadata" CSV report.

### Acceptance criteria

- [x] Add column named "First Ready on" after `Status` and before `Expiration date` to the header row and populate this field for each domain with the `first_ready` value
- [x] if a domain has no `first_ready` value, then show "(blank)"
- [ ] if `expiration_date` has no value, show "(blank)"
### Additional context


### Links to other issues

_No response_


Discussion:

Resolving patch:
diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py
--- a/src/registrar/utility/csv_export.py
+++ b/src/registrar/utility/csv_export.py
@@ -96,7 +96,8 @@ def parse_row_for_domain(
     FIELDS = {
         "Domain name": domain.name,
         "Status": domain.get_state_display(),
-        "Expiration date": domain.expiration_date,
+        "First ready on": domain.first_ready or "(blank)",
+        "Expiration date": domain.expiration_date or "(blank)",
         "Domain type": domain_type,
         "Agency": domain_info.federal_agency,
         "Organization name": domain_info.organization_name,
@@ -106,7 +107,6 @@ def parse_row_for_domain(
         "AO email": domain_info.authorizing_official.email if domain_info.authorizing_official else " ",
         "Security contact email": security_email,
         "Created at": domain.created_at,
-        "First ready": domain.first_ready,
         "Deleted": domain.deleted,
     }
 
@@ -378,13 +378,17 @@ def write_csv_for_requests(
 
 
 def export_data_type_to_csv(csv_file):
-    """All domains report with extra columns"""
+    """
+    All domains report with extra columns.
+    This maps to the "All domain metadata" button.
+    """
 
     writer = csv.writer(csv_file)
     # define columns to include in export
     columns = [
         "Domain name",
         "Status",
+        "First ready on",
         "Expiration date",
         "Domain type",
         "Agency",


Test patch:
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -97,6 +97,11 @@ def less_console_noise(output_stream=None):
             output_stream.close()
 
 
+def get_time_aware_date(date=datetime(2023, 11, 1)):
+    """Returns a time aware date"""
+    return timezone.make_aware(date)
+
+
 class GenericTestHelper(TestCase):
     """A helper class that contains various helper functions for TestCases"""
 
@@ -532,11 +537,9 @@ def setUp(self):
             username=username, first_name=first_name, last_name=last_name, email=email
         )
 
-        # Create a time-aware current date
-        current_datetime = timezone.now()
-        # Extract the date part
-        current_date = current_datetime.date()
+        current_date = get_time_aware_date(datetime(2024, 4, 2))
         # Create start and end dates using timedelta
+
         self.end_date = current_date + timedelta(days=2)
         self.start_date = current_date - timedelta(days=2)
 
@@ -544,22 +547,22 @@ def setUp(self):
         self.federal_agency_2, _ = FederalAgency.objects.get_or_create(agency="Armed Forces Retirement Home")
 
         self.domain_1, _ = Domain.objects.get_or_create(
-            name="cdomain1.gov", state=Domain.State.READY, first_ready=timezone.now()
+            name="cdomain1.gov", state=Domain.State.READY, first_ready=get_time_aware_date(datetime(2024, 4, 2))
         )
         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_5, _ = Domain.objects.get_or_create(
-            name="bdomain5.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(2023, 11, 1))
+            name="bdomain5.gov", state=Domain.State.DELETED, deleted=get_time_aware_date(datetime(2023, 11, 1))
         )
         self.domain_6, _ = Domain.objects.get_or_create(
-            name="bdomain6.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(1980, 10, 16))
+            name="bdomain6.gov", state=Domain.State.DELETED, deleted=get_time_aware_date(datetime(1980, 10, 16))
         )
         self.domain_7, _ = Domain.objects.get_or_create(
-            name="xdomain7.gov", state=Domain.State.DELETED, deleted=timezone.now()
+            name="xdomain7.gov", state=Domain.State.DELETED, deleted=get_time_aware_date(datetime(2024, 4, 2))
         )
         self.domain_8, _ = Domain.objects.get_or_create(
-            name="sdomain8.gov", state=Domain.State.DELETED, deleted=timezone.now()
+            name="sdomain8.gov", state=Domain.State.DELETED, deleted=get_time_aware_date(datetime(2024, 4, 2))
         )
         # We use timezone.make_aware to sync to server time a datetime object with the current date (using date.today())
         # and a specific time (using datetime.min.time()).
@@ -567,19 +570,19 @@ def setUp(self):
         self.domain_9, _ = Domain.objects.get_or_create(
             name="zdomain9.gov",
             state=Domain.State.DELETED,
-            deleted=timezone.make_aware(datetime.combine(date.today() - timedelta(days=1), datetime.min.time())),
+            deleted=get_time_aware_date(datetime(2024, 4, 1)),
         )
         # ready tomorrow
         self.domain_10, _ = Domain.objects.get_or_create(
             name="adomain10.gov",
             state=Domain.State.READY,
-            first_ready=timezone.make_aware(datetime.combine(date.today() + timedelta(days=1), datetime.min.time())),
+            first_ready=get_time_aware_date(datetime(2024, 4, 3)),
         )
         self.domain_11, _ = Domain.objects.get_or_create(
-            name="cdomain11.gov", state=Domain.State.READY, first_ready=timezone.now()
+            name="cdomain11.gov", state=Domain.State.READY, first_ready=get_time_aware_date(datetime(2024, 4, 2))
         )
         self.domain_12, _ = Domain.objects.get_or_create(
-            name="zdomain12.gov", state=Domain.State.READY, first_ready=timezone.now()
+            name="zdomain12.gov", state=Domain.State.READY, first_ready=get_time_aware_date(datetime(2024, 4, 2))
         )
 
         self.domain_information_1, _ = DomainInformation.objects.get_or_create(
@@ -716,23 +719,31 @@ def setUp(self):
 
         with less_console_noise():
             self.domain_request_1 = completed_domain_request(
-                status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov"
+                status=DomainRequest.DomainRequestStatus.STARTED,
+                name="city1.gov",
             )
             self.domain_request_2 = completed_domain_request(
-                status=DomainRequest.DomainRequestStatus.IN_REVIEW, name="city2.gov"
+                status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+                name="city2.gov",
             )
             self.domain_request_3 = completed_domain_request(
-                status=DomainRequest.DomainRequestStatus.STARTED, name="city3.gov"
+                status=DomainRequest.DomainRequestStatus.STARTED,
+                name="city3.gov",
             )
             self.domain_request_4 = completed_domain_request(
-                status=DomainRequest.DomainRequestStatus.STARTED, name="city4.gov"
+                status=DomainRequest.DomainRequestStatus.STARTED,
+                name="city4.gov",
             )
             self.domain_request_5 = completed_domain_request(
-                status=DomainRequest.DomainRequestStatus.APPROVED, name="city5.gov"
+                status=DomainRequest.DomainRequestStatus.APPROVED,
+                name="city5.gov",
             )
             self.domain_request_3.submit()
-            self.domain_request_3.save()
             self.domain_request_4.submit()
+
+            self.domain_request_3.submission_date = get_time_aware_date(datetime(2024, 4, 2))
+            self.domain_request_4.submission_date = get_time_aware_date(datetime(2024, 4, 2))
+            self.domain_request_3.save()
             self.domain_request_4.save()
 
     def tearDown(self):
@@ -873,6 +884,7 @@ 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
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -2164,7 +2164,6 @@ def test_user_sets_restricted_status_modal(self):
             self.assertContains(response, "Yes, select ineligible status")
 
     def test_readonly_when_restricted_creator(self):
-        self.maxDiff = None
         with less_console_noise():
             domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
             with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py
--- a/src/registrar/tests/test_reports.py
+++ b/src/registrar/tests/test_reports.py
@@ -22,9 +22,8 @@
 from botocore.exceptions import ClientError
 import boto3_mocking
 from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes  # type: ignore
-from datetime import datetime
 from django.utils import timezone
-from .common import MockDb, MockEppLib, less_console_noise
+from .common import MockDb, MockEppLib, less_console_noise, get_time_aware_date
 
 
 class CsvReportsTest(MockDb):
@@ -201,9 +200,9 @@ def setUp(self):
     def tearDown(self):
         super().tearDown()
 
-    def test_export_domains_to_writer_security_emails(self):
+    def test_export_domains_to_writer_security_emails_and_first_ready(self):
         """Test that export_domains_to_writer returns the
-        expected security email"""
+        expected security email and first_ready value"""
 
         with less_console_noise():
             # Add security email information
@@ -215,6 +214,11 @@ def test_export_domains_to_writer_security_emails(self):
             self.domain_2.security_contact
             # Invoke setter
             self.domain_3.security_contact
+
+            # Add a first ready date on the first domain. Leaving the others blank.
+            self.domain_1.first_ready = get_default_start_date()
+            self.domain_1.save()
+
             # Create a CSV file in memory
             csv_file = StringIO()
             writer = csv.writer(csv_file)
@@ -231,6 +235,7 @@ def test_export_domains_to_writer_security_emails(self):
                 "Security contact email",
                 "Status",
                 "Expiration date",
+                "First ready on",
             ]
             sort_fields = ["domain__name"]
             filter_condition = {
@@ -240,7 +245,7 @@ def test_export_domains_to_writer_security_emails(self):
                     Domain.State.ON_HOLD,
                 ],
             }
-            self.maxDiff = None
+
             # Call the export functions
             write_csv_for_domains(
                 writer,
@@ -259,13 +264,14 @@ def test_export_domains_to_writer_security_emails(self):
             # sorted alphabetially by domain name
             expected_content = (
                 "Domain name,Domain type,Agency,Organization name,City,State,AO,"
-                "AO email,Security contact email,Status,Expiration date\n"
-                "adomain10.gov,Federal,Armed Forces Retirement Home,Ready\n"
-                "adomain2.gov,Interstate,(blank),Dns needed\n"
-                "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady\n"
-                "ddomain3.gov,Federal,Armed Forces Retirement Home,security@mail.gov,On hold,2023-11-15\n"
-                "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,(blank),Ready\n"
-                "zdomain12.govInterstateReady\n"
+                "AO email,Security contact email,Status,Expiration date, First ready on\n"
+                "adomain10.gov,Federal,Armed Forces Retirement Home,Ready,(blank),2024-04-03\n"
+                "adomain2.gov,Interstate,(blank),Dns needed,(blank),(blank)\n"
+                "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank),2024-04-02\n"
+                "ddomain3.gov,Federal,Armed Forces Retirement Home,security@mail.gov,On hold,2023-11-15,(blank)\n"
+                "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,"
+                "(blank),Ready,(blank),2023-11-01\n"
+                "zdomain12.govInterstateReady,(blank),2024-04-02\n"
             )
             # Normalize line endings and remove commas,
             # spaces and leading/trailing whitespace
@@ -470,18 +476,18 @@ def test_write_domains_body_with_date_filter_pulls_domains_in_range(self):
             # Read the content into a variable
             csv_content = csv_file.read()
 
-            # We expect READY domains first, created between today-2 and today+2, sorted by created_at then name
-            # and DELETED domains deleted between today-2 and today+2, sorted by deleted then name
+            # We expect READY domains first, created between day-2 and day+2, sorted by created_at then name
+            # and DELETED domains deleted between day-2 and day+2, sorted by deleted then name
             expected_content = (
                 "Domain name,Domain type,Agency,Organization name,City,"
                 "State,Status,Expiration date\n"
-                "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,\n"
-                "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,\n"
-                "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady\n"
-                "zdomain12.govInterstateReady\n"
-                "zdomain9.gov,Federal,Armed Forces Retirement Home,,,,Deleted,\n"
-                "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,\n"
-                "xdomain7.gov,Federal,Armed Forces Retirement Home,,,,Deleted,\n"
+                "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n"
+                "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n"
+                "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n"
+                "zdomain12.govInterstateReady(blank)\n"
+                "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank)\n"
+                "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank)\n"
+                "xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank)\n"
             )
 
             # Normalize line endings and remove commas,
@@ -526,7 +532,7 @@ def test_export_domains_to_writer_domain_managers(self):
                     Domain.State.ON_HOLD,
                 ],
             }
-            self.maxDiff = None
+
             # Call the export functions
             write_csv_for_domains(
                 writer,
@@ -548,14 +554,14 @@ def test_export_domains_to_writer_domain_managers(self):
                 "Organization name,City,State,AO,AO email,"
                 "Security contact email,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
                 "Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
-                "adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,squeaker@rocks.com, I\n"
-                "adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com, R,squeaker@rocks.com, I\n"
-                "cdomain11.govReadyFederal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.comR\n"
-                "cdomain1.gov,Ready,,Federal - Executive,World War I Centennial Commission,,,"
+                "adomain10.gov,Ready,(blank),Federal,Armed Forces Retirement Home,,,, , ,squeaker@rocks.com, I\n"
+                "adomain2.gov,Dns needed,(blank),Interstate,,,,, , , ,meoward@rocks.com, R,squeaker@rocks.com, I\n"
+                "cdomain11.govReady,(blank),Federal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.comR\n"
+                "cdomain1.gov,Ready,(blank),Federal - Executive,World War I Centennial Commission,,,"
                 ", , , ,meoward@rocks.com,R,info@example.com,R,big_lebowski@dude.co,R,"
                 "woofwardthethird@rocks.com,I\n"
-                "ddomain3.gov,On hold,,Federal,Armed Forces Retirement Home,,,, , , ,,\n"
-                "zdomain12.govReadyInterstatemeoward@rocks.comR\n"
+                "ddomain3.gov,On hold,(blank),Federal,Armed Forces Retirement Home,,,, , , ,,\n"
+                "zdomain12.gov,Ready,(blank),Interstate,meoward@rocks.com,R\n"
             )
             # Normalize line endings and remove commas,
             # spaces and leading/trailing whitespace
@@ -580,7 +586,7 @@ def test_export_data_managed_domains_to_csv(self):
             csv_file.seek(0)
             # Read the content into a variable
             csv_content = csv_file.read()
-            self.maxDiff = None
+
             # We expect the READY domain names with the domain managers: Their counts, and listing at end_date.
             expected_content = (
                 "MANAGED DOMAINS COUNTS AT START DATE\n"
@@ -623,7 +629,7 @@ def test_export_data_unmanaged_domains_to_csv(self):
             csv_file.seek(0)
             # Read the content into a variable
             csv_content = csv_file.read()
-            self.maxDiff = None
+
             # We expect the READY domain names with the domain managers: Their counts, and listing at end_date.
             expected_content = (
                 "UNMANAGED DOMAINS AT START DATE\n"
@@ -698,7 +704,7 @@ class HelperFunctions(MockDb):
     """This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy."""
 
     def test_get_default_start_date(self):
-        expected_date = timezone.make_aware(datetime(2023, 11, 1))
+        expected_date = get_time_aware_date()
         actual_date = get_default_start_date()
         self.assertEqual(actual_date, expected_date)