Repository: cisagov/manage.get.gov

Issue:
Data Migration: Domain Invitation Email send
### Issue Description

We will be sending emails separate of data ingestions. We want to avoid inviting already invited users while being able to update data from the to-end registry.
- For users with more than one domain, only send one email but include the links to all their domains not just the single domain.
- This script is a one time design for use with the migration. 
- This script should be able to be run multiple times with new sets of data, if the domain invitation has already been sent then it should NOT re-send for that email address 
  - The email is sent when transition_domain.email_sent=False. It should update this field in the table as it sends the emails. 
  - Ensure this is true even in the case where one user has multiple domains (all those domain rows should now have the same flag switched
- This should be VERY well tested. Include tests in the script or in a supporting script. Do not use it in production or with the actual emails from our host data until we are ready to execute the user Migration into our system. Test with fake emails or emails from team members.

### Additional Context (optional)

NOTE: Use the current domain invitation verbiage, run it by design to ensure it will be good given that it is for the migration vs normal invitations. 
https://docs.google.com/document/d/1UkQZdAmcGprXWdHX_PuT155CCNPbf_Gf/edit?pli=1#heading=h.iqv2v8lecjpn

retest this after #923 is done with all the data from Verisign
### Issue Links

blocked by: #923 


Discussion:
Below are previews of two sample emails to be sent during migration.  These are added here for additional refinement.

First email is sent to a domain manager with only one domain to manage:

![domain manager with single domain](https://github.com/cisagov/getgov/assets/111779554/a647e1b4-934b-438e-9bd2-1a00a28d697f)

Second email is sent to a domain manager with multiple domains to manage:

![domain manager with multiple domains](https://github.com/cisagov/getgov/assets/111779554/cf53d835-3d94-4256-b4da-0587603b813c)


Resolving patch:
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -639,6 +639,21 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
         return super().change_view(request, object_id, form_url, extra_context)
 
 
+class TransitionDomainAdmin(ListHeaderAdmin):
+    """Custom transition domain admin class."""
+
+    # Columns
+    list_display = [
+        "username",
+        "domain_name",
+        "status",
+        "email_sent",
+    ]
+
+    search_fields = ["username", "domain_name"]
+    search_help_text = "Search by user or domain name."
+
+
 class DomainInformationInline(admin.StackedInline):
     """Edit a domain information on the domain page.
     We had issues inheriting from both StackedInline
@@ -801,4 +816,4 @@ class DraftDomainAdmin(ListHeaderAdmin):
 admin.site.register(models.Website, WebsiteAdmin)
 admin.site.register(models.PublicContact, AuditedAdmin)
 admin.site.register(models.DomainApplication, DomainApplicationAdmin)
-admin.site.register(models.TransitionDomain, AuditedAdmin)
+admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
diff --git a/src/registrar/management/commands/send_domain_invitations.py b/src/registrar/management/commands/send_domain_invitations.py
new file mode 100644
--- /dev/null
+++ b/src/registrar/management/commands/send_domain_invitations.py
@@ -0,0 +1,142 @@
+"""Data migration: Send domain invitations once to existing customers."""
+
+import logging
+import copy
+
+from django.core.management import BaseCommand
+from registrar.models import TransitionDomain
+from ...utility.email import send_templated_email, EmailSendingError
+from typing import List
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+    help = "Send domain invitations once to existing customers."
+
+    # this array is used to store and process the transition_domains
+    transition_domains: List[str] = []
+    # this array is used to store domains with errors, which are not
+    # sent emails; this array is used to update the succesful
+    # transition_domains to email_sent=True, and also to report
+    # out errors
+    domains_with_errors: List[str] = []
+    # this array is used to store email_context; each item in the array
+    # contains the context for a single email; single emails may be 1
+    # or more transition_domains, as they are grouped by username
+    emails_to_send: List[str] = []
+
+    def add_arguments(self, parser):
+        """Add command line arguments."""
+        parser.add_argument(
+            "-s",
+            "--send_emails",
+            action="store_true",
+            default=False,
+            dest="send_emails",
+            help="Send emails ",
+        )
+
+    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")
+
+        self.build_emails_to_send_array()
+
+        if options["send_emails"]:
+            logger.info("about to send emails")
+            self.send_emails()
+            logger.info("done sending emails")
+
+            self.update_domains_as_sent()
+
+            logger.info("done sending emails and updating transition_domains")
+        else:
+            logger.info("not sending emails")
+
+    def build_emails_to_send_array(self):
+        """this method sends emails to distinct usernames"""
+
+        # data structure to hold email context for a single email;
+        # transition_domains ordered by username, a single email_context
+        # may include information from more than one transition_domain
+        email_context = {"email": ""}
+
+        # loop through all transition_domains; group them by username
+        # into emails_to_send_array
+        for transition_domain in self.transition_domains:
+            # attempt to get the domain from domain objects; if there is
+            # an error getting the domain, skip this domain and add it to
+            # domains_with_errors
+            try:
+                # if prior username does not match current username
+                if (
+                    not email_context["email"]
+                    or email_context["email"] != transition_domain.username
+                ):
+                    # if not first in list of transition_domains
+                    if email_context["email"]:
+                        # append the email context to the emails_to_send array
+                        self.emails_to_send.append(copy.deepcopy(email_context))
+                    email_context["domains"] = []
+                email_context["email"] = transition_domain.username
+                email_context["domains"].append(transition_domain.domain_name)
+            except Exception as err:
+                # error condition if domain not in database
+                self.domains_with_errors.append(
+                    copy.deepcopy(transition_domain.domain_name)
+                )
+                logger.error(
+                    f"error retrieving domain {transition_domain.domain_name}: {err}"
+                )
+        # if there are at least one more transition domains than errors,
+        # then append one more item
+        if len(self.transition_domains) > len(self.domains_with_errors):
+            self.emails_to_send.append(email_context)
+
+    def send_emails(self):
+        if len(self.emails_to_send) > 0:
+            for email_data in self.emails_to_send:
+                self.send_email(email_data)
+        else:
+            logger.info("no emails to send")
+
+    def send_email(self, email_data):
+        try:
+            send_templated_email(
+                "emails/transition_domain_invitation.txt",
+                "emails/transition_domain_invitation_subject.txt",
+                to_address=email_data["email"],
+                context={
+                    "domains": email_data["domains"],
+                },
+            )
+            # success message is logged
+            logger.info(
+                f"email sent successfully to {email_data['email']} for "
+                f"{[domain for domain in email_data['domains']]}"
+            )
+        except EmailSendingError as err:
+            logger.error(
+                f"email did not send successfully to {email_data['email']} "
+                f"for {[domain for domain in email_data['domains']]}"
+                f": {err}"
+            )
+            # if email failed to send, set error in domains_with_errors for each
+            # domain in the email so that transition domain email_sent is not set
+            # to True
+            for domain in email_data["domains"]:
+                self.domains_with_errors.append(domain)
+
+    def update_domains_as_sent(self):
+        """set email_sent to True in all transition_domains which have
+        been processed successfully"""
+        for transition_domain in self.transition_domains:
+            if transition_domain.domain_name not in self.domains_with_errors:
+                transition_domain.email_sent = True
+                transition_domain.save()


Test patch:
diff --git a/src/registrar/management/commands/generate_test_transition_domains.py b/src/registrar/management/commands/generate_test_transition_domains.py
new file mode 100644
--- /dev/null
+++ b/src/registrar/management/commands/generate_test_transition_domains.py
@@ -0,0 +1,65 @@
+"""Data migration: Generate fake transition domains, replacing existing ones."""
+
+import logging
+
+from django.core.management import BaseCommand
+from registrar.models import TransitionDomain, Domain
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+    help = "Generate test transition domains from existing domains"
+
+    # Generates test transition domains for testing send_domain_invitations script.
+    # Running this script removes all existing transition domains, so use with caution.
+    # Transition domains are created with email addresses provided as command line
+    # argument. Email addresses for testing are passed as comma delimited list of
+    # email addresses, and are required to be provided.  Email addresses from the list
+    # are assigned to transition domains at time of creation.
+
+    def add_arguments(self, parser):
+        """Add command line arguments."""
+        parser.add_argument(
+            "-e",
+            "--emails",
+            required=True,
+            dest="emails",
+            help="Comma-delimited list of email addresses to be used for testing",
+        )
+
+    def handle(self, **options):
+        """Delete existing TransitionDomains.  Generate test ones.
+        expects options[emails]; emails will be assigned to transition
+        domains at the time of creation"""
+
+        # split options[emails] into an array of test emails
+        test_emails = options["emails"].split(",")
+
+        if len(test_emails) > 0:
+            # set up test data
+            self.delete_test_transition_domains()
+            self.load_test_transition_domains(test_emails)
+        else:
+            logger.error("list of emails for testing is required")
+
+    def load_test_transition_domains(self, test_emails: list):
+        """Load test transition domains"""
+
+        # counter for test_emails index
+        test_emails_counter = 0
+        # Need to get actual domain names from the database for this test
+        real_domains = Domain.objects.all()
+        for real_domain in real_domains:
+            TransitionDomain.objects.create(
+                username=test_emails[test_emails_counter % len(test_emails)],
+                domain_name=real_domain.name,
+                status="created",
+                email_sent=False,
+            )
+            test_emails_counter += 1
+
+    def delete_test_transition_domains(self):
+        self.transition_domains = TransitionDomain.objects.all()
+        for transition_domain in self.transition_domains:
+            transition_domain.delete()