Repository: mozmeao/basket

Issue:
CTMS should generate the email ID for new contacts
CTMS email IDs are v4 UUIDs, so they could be generated from Basket or from CTMS. However, if this changes in the future, Basket would need to update as well. To avoid problems, let's let CTMS generate the email_id and Basket can read it.


Discussion:

Resolving patch:
diff --git a/basket/news/backends/ctms.py b/basket/news/backends/ctms.py
--- a/basket/news/backends/ctms.py
+++ b/basket/news/backends/ctms.py
@@ -10,6 +10,7 @@
 from django.core.cache import cache
 from django_statsd.clients import statsd
 
+import sentry_sdk
 from requests_oauthlib import OAuth2Session
 from oauthlib.oauth2 import BackendApplicationClient
 
@@ -594,7 +595,11 @@ def add(self, data):
         """
         if not self.interface:
             return None
-        return self.interface.post_to_create(to_vendor(data))
+        try:
+            return self.interface.post_to_create(to_vendor(data))
+        except Exception:
+            sentry_sdk.capture_exception()
+            return None
 
     def update(self, existing_data, update_data):
         """
@@ -609,7 +614,11 @@ def update(self, existing_data, update_data):
         email_id = existing_data.get("email_id")
         if not email_id:
             raise ValueError("No email_id in existing data.")
-        return self.interface.patch_by_email_id(email_id, to_vendor(update_data))
+        try:
+            return self.interface.patch_by_email_id(email_id, to_vendor(update_data))
+        except Exception:
+            sentry_sdk.capture_exception()
+            return None
 
 
 def ctms_session():
diff --git a/basket/news/tasks.py b/basket/news/tasks.py
--- a/basket/news/tasks.py
+++ b/basket/news/tasks.py
@@ -564,14 +564,10 @@ def upsert_contact(api_call_type, data, user_data):
             sfdc_add_update.delay(update_data)
         else:
             ctms_data = update_data.copy()
-            email_id = ctms_data["email_id"] = generate_token()
-            try:
-                ctms.add(ctms_data)
-            except Exception:
-                sentry_sdk.capture_exception()
-            else:
+            ctms_contact = ctms.add(ctms_data)
+            if ctms_contact:
                 # Successfully added to CTMS, send email_id to SFDC
-                update_data["email_id"] = email_id
+                update_data["email_id"] = ctms_contact["email"]["email_id"]
 
             # don't catch exceptions here. SalesforceError subclasses will retry.
             sfdc.add(update_data)
@@ -605,10 +601,7 @@ def upsert_contact(api_call_type, data, user_data):
         # During the transition from SFDC to CTMS, only update if there is a
         # matching CTMS record
         if user_data.get("email_id"):
-            try:
-                ctms.update(user_data, update_data)
-            except Exception:
-                sentry_sdk.capture_exception()
+            ctms.update(user_data, update_data)
 
     if send_confirm and settings.SEND_CONFIRM_MESSAGES:
         send_confirm_message.delay(
@@ -631,8 +624,11 @@ def sfdc_add_update(update_data, user_data=None):
             # Only update when CTMS has a matching record
             ctms.update(user_data, update_data)
     else:
-        if not update_data.get("email_id"):
-            update_data["email_id"] = generate_token()
+        ctms_data = update_data.copy()
+        ctms_contact = ctms.add(ctms_data)
+        if ctms_contact:
+            update_data["email_id"] = ctms_contact["email"]["email_id"]
+
         try:
             sfdc.add(update_data)
         except sfapi.SalesforceMalformedRequest as e:  # noqa
@@ -648,11 +644,10 @@ def sfdc_add_update(update_data, user_data=None):
                     ctms.update(user_data, update_data)
             else:
                 # still no user, try the add one more time
+                ctms_contact = ctms.add(update_data)
+                if ctms_contact:
+                    update_data["email_id"] = ctms_contact["email"]["email_id"]
                 sfdc.add(update_data)
-                ctms.add(update_data)
-        else:
-            # Add the related CTMS record
-            ctms.add(update_data)
 
 
 @et_task


Test patch:
diff --git a/basket/news/tests/test_upsert_user.py b/basket/news/tests/test_upsert_user.py
--- a/basket/news/tests/test_upsert_user.py
+++ b/basket/news/tests/test_upsert_user.py
@@ -1,8 +1,8 @@
+from uuid import uuid4
+
 from django.test import TestCase, override_settings
 
 from mock import patch, ANY
-from requests import Response
-from requests.exceptions import HTTPError
 
 from basket.news import models
 from basket.news.tasks import upsert_user
@@ -34,6 +34,8 @@ def test_update_first_last_names(
     ):
         """sending name fields should result in names being passed to SF"""
         get_user_data.return_value = None  # Does not exist yet
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         models.Newsletter.objects.create(
             slug="slug",
             title="title",
@@ -55,9 +57,9 @@ def test_update_first_last_names(
         sfdc_data = data.copy()
         sfdc_data["newsletters"] = {"slug": True}
         sfdc_data["token"] = ANY
-        sfdc_data["email_id"] = ANY
-        sfdc_mock.add.assert_called_with(sfdc_data)
         ctms_mock.add.assert_called_with(sfdc_data)
+        sfdc_data["email_id"] = email_id
+        sfdc_mock.add.assert_called_with(sfdc_data)
 
     def test_update_user_set_works_if_no_newsletters(
         self, get_user_data, sfdc_mock, ctms_mock, confirm_mock,
@@ -284,6 +286,8 @@ def test_update_user_with_email_id(
     def test_send_confirm(self, get_user_data, sfdc_mock, ctms_mock, confirm_mock):
         """Subscribing to a newsletter should send a confirm email"""
         get_user_data.return_value = None  # Does not exist yet
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         models.Newsletter.objects.create(
             slug="slug",
             title="title",
@@ -303,14 +307,16 @@ def test_send_confirm(self, get_user_data, sfdc_mock, ctms_mock, confirm_mock):
         sfdc_data = data.copy()
         sfdc_data["newsletters"] = {"slug": True}
         sfdc_data["token"] = ANY
-        sfdc_data["email_id"] = ANY
-        sfdc_mock.add.assert_called_with(sfdc_data)
         ctms_mock.add.assert_called_with(sfdc_data)
+        sfdc_data["email_id"] = email_id
+        sfdc_mock.add.assert_called_with(sfdc_data)
         confirm_mock.delay.assert_called_with(self.email, ANY, "en", "moz")
 
     def test_send_fx_confirm(self, get_user_data, sfdc_mock, ctms_mock, confirm_mock):
         """Subscribing to a Fx newsletter should send a Fx confirm email"""
         get_user_data.return_value = None  # Does not exist yet
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         models.Newsletter.objects.create(
             slug="slug",
             title="title",
@@ -331,14 +337,16 @@ def test_send_fx_confirm(self, get_user_data, sfdc_mock, ctms_mock, confirm_mock
         sfdc_data = data.copy()
         sfdc_data["newsletters"] = {"slug": True}
         sfdc_data["token"] = ANY
-        sfdc_data["email_id"] = ANY
-        sfdc_mock.add.assert_called_with(sfdc_data)
         ctms_mock.add.assert_called_with(sfdc_data)
+        sfdc_data["email_id"] = email_id
+        sfdc_mock.add.assert_called_with(sfdc_data)
         confirm_mock.delay.assert_called_with(self.email, ANY, "en", "fx")
 
     def test_send_moz_confirm(self, get_user_data, sfdc_mock, ctms_mock, confirm_mock):
         """Subscribing to a Fx and moz newsletters should send a moz confirm email"""
         get_user_data.return_value = None  # Does not exist yet
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         models.Newsletter.objects.create(
             slug="slug",
             title="title",
@@ -368,9 +376,9 @@ def test_send_moz_confirm(self, get_user_data, sfdc_mock, ctms_mock, confirm_moc
         sfdc_data = data.copy()
         sfdc_data["newsletters"] = {"slug": True, "slug2": True}
         sfdc_data["token"] = ANY
-        sfdc_data["email_id"] = ANY
-        sfdc_mock.add.assert_called_with(sfdc_data)
         ctms_mock.add.assert_called_with(sfdc_data)
+        sfdc_data["email_id"] = email_id
+        sfdc_mock.add.assert_called_with(sfdc_data)
         confirm_mock.delay.assert_called_with(self.email, ANY, "en", "moz")
 
     def test_no_send_confirm_newsletter(
@@ -381,6 +389,8 @@ def test_no_send_confirm_newsletter(
         if the newsletter does not require it
         """
         get_user_data.return_value = None  # Does not exist yet
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         models.Newsletter.objects.create(
             slug="slug",
             title="title",
@@ -400,10 +410,10 @@ def test_no_send_confirm_newsletter(
         sfdc_data = data.copy()
         sfdc_data["newsletters"] = {"slug": True}
         sfdc_data["token"] = ANY
-        sfdc_data["email_id"] = ANY
         sfdc_data["optin"] = True
-        sfdc_mock.add.assert_called_with(sfdc_data)
         ctms_mock.add.assert_called_with(sfdc_data)
+        sfdc_data["email_id"] = email_id
+        sfdc_mock.add.assert_called_with(sfdc_data)
         confirm_mock.delay.assert_not_called()
 
     def test_no_send_confirm_user(
@@ -444,11 +454,7 @@ def test_new_subscription_with_ctms_conflict(
     ):
         """When CTMS returns an error for a new contact, the email_id is not sent to SFDC"""
         get_user_data.return_value = None  # Does not exist yet
-        ctms_resp = Response()
-        ctms_resp.status_code = 409
-        ctms_resp._content = '"detail": "Contact already exists"}'
-        ctms_err = HTTPError(response=ctms_resp)
-        ctms_mock.add.side_effect = ctms_err
+        ctms_mock.add.return_value = None  # Conflict on create
         models.Newsletter.objects.create(
             slug="slug",
             title="title",
@@ -468,8 +474,6 @@ def test_new_subscription_with_ctms_conflict(
         sfdc_data = data.copy()
         sfdc_data["newsletters"] = {"slug": True}
         sfdc_data["token"] = ANY
-        sfdc_data["email_id"] = ANY
         ctms_mock.add.assert_called_with(sfdc_data)
-        del sfdc_data["email_id"]
         sfdc_mock.add.assert_called_with(sfdc_data)
         confirm_mock.delay.assert_called_with(self.email, ANY, "en", "moz")