Repository: cisagov/manage.get.gov

Issue:
Modify domain invitation script to process "friends of the show" first, then incrementally process others
### Issue description

We have a few domain managers that we'll invite to the registrar first. Let's modify the domain invitation script to send to a defined set of domains first. 

We also shouldn't blast thousands of emails out to the internet, but incrementally roll them out.

### Acceptance criteria

- [ ] Invitation script works with a product owner-specified list of domains/contacts (before sending to everyone else)
- [ ] Script slow rolls out invitations. Could be percentage-based (1/2/5/10/20/45/75/100) or volume-based (a few hundred at a time)

### Additional context

_No response_

### Links to other issues

🔄 Related to PR #1038


Discussion:

Resolving patch:
diff --git a/src/registrar/management/commands/send_domain_invitations.py b/src/registrar/management/commands/send_domain_invitations.py
--- a/src/registrar/management/commands/send_domain_invitations.py
+++ b/src/registrar/management/commands/send_domain_invitations.py
@@ -37,14 +37,24 @@ def add_arguments(self, parser):
             help="Send emails ",
         )
 
+        parser.add_argument("emails", nargs="*", help="Email addresses to send invitations to")
+
     def handle(self, **options):
         """Process the objects in TransitionDomain."""
 
         logger.info("checking domains and preparing emails")
-        # Get all TransitionDomain objects
-        self.transition_domains = TransitionDomain.objects.filter(
-            email_sent=False,
-        ).order_by("username")
+
+        if options["emails"]:
+            # this option is a list of email addresses
+            self.transition_domains = TransitionDomain.objects.filter(
+                username__in=options["emails"],
+                email_sent=False,
+            ).order_by("username")
+        else:
+            # Get all TransitionDomain objects
+            self.transition_domains = TransitionDomain.objects.filter(
+                email_sent=False,
+            ).order_by("username")
         logger.info("Found %d transition domains", len(self.transition_domains))
 
         self.build_emails_to_send_array()


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
@@ -58,7 +58,7 @@ def get_handlers():
 
 
 @contextmanager
-def less_console_noise():
+def less_console_noise(output_stream=None):
     """
     Context manager to use in tests to silence console logging.
 
@@ -66,14 +66,19 @@ def less_console_noise():
     (such as errors) which are normal and expected.
 
     It can easily be removed to debug a failing test.
+
+    Arguments:
+        `output_stream`: a stream to redirect every handler to. If it's
+        not provided, use /dev/null.
     """
     restore = {}
     handlers = get_handlers()
-    devnull = open(os.devnull, "w")
+    if output_stream is None:
+        output_stream = open(os.devnull, "w")
 
     # redirect all the streams
     for handler in handlers.values():
-        prior = handler.setStream(devnull)
+        prior = handler.setStream(output_stream)
         restore[handler.name] = prior
     try:
         # run the test
@@ -82,8 +87,9 @@ def less_console_noise():
         # restore the streams
         for handler in handlers.values():
             handler.setStream(restore[handler.name])
-        # close the file we opened
-        devnull.close()
+        if output_stream is None:
+            # we opened output_stream so we have to close it
+            output_stream.close()
 
 
 class MockUserLogin:
diff --git a/src/registrar/tests/test_transition_domain_migrations.py b/src/registrar/tests/test_transition_domain_migrations.py
--- a/src/registrar/tests/test_transition_domain_migrations.py
+++ b/src/registrar/tests/test_transition_domain_migrations.py
@@ -1,4 +1,7 @@
 import datetime
+
+from io import StringIO
+
 from django.test import TestCase
 
 from registrar.models import (
@@ -13,6 +16,8 @@
 from django.core.management import call_command
 from unittest.mock import patch
 
+from .common import less_console_noise
+
 
 class TestMigrations(TestCase):
     def setUp(self):
@@ -369,3 +374,42 @@ def test_logins(self):
             expected_missing_domain_informations,
             expected_missing_domain_invitations,
         )
+
+    def test_send_domain_invitations_email(self):
+        """Can send only a single domain invitation email."""
+        with less_console_noise():
+            self.run_load_domains()
+            self.run_transfer_domains()
+
+        # this is one of the email addresses in data/test_contacts.txt
+        output_stream = StringIO()
+        # also have to re-point the logging handlers to output_stream
+        with less_console_noise(output_stream):
+            call_command("send_domain_invitations", "testuser@gmail.com", stdout=output_stream)
+
+        # Check that we had the right numbers in our output
+        output = output_stream.getvalue()
+        # should only be one domain we send email for
+        self.assertIn("Found 1 transition domains", output)
+        self.assertTrue("would send email to testuser@gmail.com", output)
+
+    def test_send_domain_invitations_two_emails(self):
+        """Can send only a single domain invitation email."""
+        with less_console_noise():
+            self.run_load_domains()
+            self.run_transfer_domains()
+
+        # these are two email addresses in data/test_contacts.txt
+        output_stream = StringIO()
+        # also have to re-point the logging handlers to output_stream
+        with less_console_noise(output_stream):
+            call_command(
+                "send_domain_invitations", "testuser@gmail.com", "agustina.wyman7@test.com", stdout=output_stream
+            )
+
+        # Check that we had the right numbers in our output
+        output = output_stream.getvalue()
+        # should only be one domain we send email for
+        self.assertIn("Found 2 transition domains", output)
+        self.assertTrue("would send email to testuser@gmail.com", output)
+        self.assertTrue("would send email to agustina.wyman7@test.com", output)