From 7a821b7fd68d437cc9db1c14472f6d382d4ad6f1 Mon Sep 17 00:00:00 2001 From: Michael Terry Date: Fri, 14 May 2021 11:27:46 -0400 Subject: [PATCH] refactor!: Remove sailthru and braze specific modules (#134) Instead, use a more generically named email module that abstracts whatever provider we're using under the covers: ecommerce_worker.email.v1.api.* The public API are four celery tasks and one bounce-check method: - send_code_assignment_nudge_email - send_offer_assignment_email - send_offer_update_email - send_offer_usage_email - did_email_bounce --- ecommerce_worker/configuration/base.py | 54 +- ecommerce_worker/configuration/test.py | 24 - ecommerce_worker/{braze => email}/__init__.py | 0 .../{braze => email}/v1/__init__.py | 0 ecommerce_worker/email/v1/api.py | 17 + .../v1/tests => email/v1/braze}/__init__.py | 0 .../{braze/v1 => email/v1/braze}/client.py | 14 +- .../v1 => email/v1/braze}/exceptions.py | 0 ecommerce_worker/email/v1/braze/tasks.py | 154 +++ .../v1/braze/tests}/__init__.py | 0 .../v1/braze}/tests/test_client.py | 44 +- .../email/v1/braze/tests/test_tasks.py | 304 ++++++ ecommerce_worker/email/v1/tasks.py | 95 ++ .../v1 => email/v1/tests}/__init__.py | 0 ecommerce_worker/email/v1/tests/test_api.py | 22 + ecommerce_worker/email/v1/tests/test_utils.py | 80 ++ ecommerce_worker/email/v1/utils.py | 45 + .../fulfillment/v1/tests/test_tasks.py | 2 +- ecommerce_worker/sailthru/v1/constants.py | 1 - ecommerce_worker/sailthru/v1/exceptions.py | 13 - ecommerce_worker/sailthru/v1/notification.py | 139 --- ecommerce_worker/sailthru/v1/tasks.py | 658 ------------ .../sailthru/v1/tests/__init__.py | 0 .../sailthru/v1/tests/test_tasks.py | 946 ------------------ .../sailthru/v1/tests/test_utils.py | 69 -- ecommerce_worker/sailthru/v1/utils.py | 71 -- requirements/base.in | 1 - requirements/base.txt | 88 +- requirements/optional.txt | 3 +- requirements/pip_tools.txt | 8 +- requirements/production.txt | 129 ++- requirements/test.txt | 178 ++-- requirements/tox.txt | 13 +- setup.py | 2 +- 34 files changed, 1023 insertions(+), 2151 deletions(-) rename ecommerce_worker/{braze => email}/__init__.py (100%) rename ecommerce_worker/{braze => email}/v1/__init__.py (100%) create mode 100644 ecommerce_worker/email/v1/api.py rename ecommerce_worker/{braze/v1/tests => email/v1/braze}/__init__.py (100%) rename ecommerce_worker/{braze/v1 => email/v1/braze}/client.py (98%) rename ecommerce_worker/{braze/v1 => email/v1/braze}/exceptions.py (100%) create mode 100644 ecommerce_worker/email/v1/braze/tasks.py rename ecommerce_worker/{sailthru => email/v1/braze/tests}/__init__.py (100%) rename ecommerce_worker/{braze/v1 => email/v1/braze}/tests/test_client.py (88%) create mode 100644 ecommerce_worker/email/v1/braze/tests/test_tasks.py create mode 100644 ecommerce_worker/email/v1/tasks.py rename ecommerce_worker/{sailthru/v1 => email/v1/tests}/__init__.py (100%) create mode 100644 ecommerce_worker/email/v1/tests/test_api.py create mode 100644 ecommerce_worker/email/v1/tests/test_utils.py create mode 100644 ecommerce_worker/email/v1/utils.py delete mode 100644 ecommerce_worker/sailthru/v1/constants.py delete mode 100644 ecommerce_worker/sailthru/v1/exceptions.py delete mode 100644 ecommerce_worker/sailthru/v1/notification.py delete mode 100644 ecommerce_worker/sailthru/v1/tasks.py delete mode 100644 ecommerce_worker/sailthru/v1/tests/__init__.py delete mode 100644 ecommerce_worker/sailthru/v1/tests/test_tasks.py delete mode 100644 ecommerce_worker/sailthru/v1/tests/test_utils.py delete mode 100644 ecommerce_worker/sailthru/v1/utils.py diff --git a/ecommerce_worker/configuration/base.py b/ecommerce_worker/configuration/base.py index 515faeb1..b097f514 100644 --- a/ecommerce_worker/configuration/base.py +++ b/ecommerce_worker/configuration/base.py @@ -1,5 +1,5 @@ # CELERY -# Default broker URL. See http://celery.readthedocs.org/en/latest/configuration.html#broker-url. +# Default broker URL. See http://celery.readthedocs.org/en/4.0/userguide/configuration.html#std:setting-broker_url. BROKER_URL = 'redis://:celery@127.0.0.1:6379' # Disable connection pooling. Connections may be severed by load balancers. @@ -15,14 +15,14 @@ BROKER_HEARTBEAT_CHECKRATE = 2 # Backend used to store task results. -# See http://celery.readthedocs.org/en/latest/configuration.html#celery-result-backend. +# See http://celery.readthedocs.org/en/4.0/userguide/configuration.html#std:setting-result_backend. CELERY_RESULT_BACKEND = None # A sequence of modules to import when the worker starts. -# See http://celery.readthedocs.org/en/latest/configuration.html#celery-imports. +# See http://celery.readthedocs.org/en/4.0/userguide/configuration.html#std:setting-imports. CELERY_IMPORTS = ( + 'ecommerce_worker.email.v1.tasks', 'ecommerce_worker.fulfillment.v1.tasks', - 'ecommerce_worker.sailthru.v1.tasks', ) DEFAULT_PRIORITY_QUEUE = 'ecommerce.default' @@ -30,7 +30,7 @@ CELERY_DEFAULT_ROUTING_KEY = 'ecommerce' CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE # Prevent Celery from removing handlers on the root logger. Allows setting custom logging handlers. -# See http://celery.readthedocs.org/en/latest/configuration.html#celeryd-hijack-root-logger. +# See http://celery.readthedocs.org/en/4.0/userguide/configuration.html#std:setting-worker_hijack_root_logger. CELERYD_HIJACK_ROOT_LOGGER = False # Specify allowed serializers that are consistent with Celery 3 defaults @@ -66,7 +66,7 @@ # .. toggle_name: BRAZE['BRAZE_ENABLE'] # .. toggle_implementation: PythonConstant # .. toggle_default: False -# .. toggle_description: Toggle for allowing emails to be sent via Braze instead of sailthru +# .. toggle_description: Toggle for allowing emails to be sent via Braze # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2021-02-09 # .. toggle_target_removal_date: None @@ -88,46 +88,4 @@ # Retry settings for Braze celery tasks 'BRAZE_RETRY_SECONDS': 3600, 'BRAZE_RETRY_ATTEMPTS': 6, - } - -# Settings for Sailthru email marketing integration -SAILTHRU = { - # Set to false to ignore Sailthru events - 'SAILTHRU_ENABLE': False, - - # Template used when user upgrades to verified - 'SAILTHRU_UPGRADE_TEMPLATE': None, - - # Template used with user purchases a course - 'SAILTHRU_PURCHASE_TEMPLATE': None, - - # Template used with user enrolls in a free course - 'SAILTHRU_ENROLL_TEMPLATE': None, - - # Abandoned cart template - 'SAILTHRU_ABANDONED_CART_TEMPLATE': None, - - # minutes to delay before abandoned cart message - 'SAILTHRU_ABANDONED_CART_DELAY': 60, - - # Sailthru key and secret required for integration - 'SAILTHRU_KEY': 'sailthru key here', - 'SAILTHRU_SECRET': 'sailthru secret here', - - # Retry settings for Sailthru celery tasks - 'SAILTHRU_RETRY_SECONDS': 3600, - 'SAILTHRU_RETRY_ATTEMPTS': 6, - - # ttl for cached course content from Sailthru (in seconds) - 'SAILTHRU_CACHE_TTL_SECONDS': 3600, - - # dummy price for audit/honor (i.e., if cost = 0) - # Note: setting this value to 0 skips Sailthru calls for free transactions - 'SAILTHRU_MINIMUM_COST': 100, - - # Transactional email template name map - 'templates': { - 'assignment_email': 'Offer Assignment Email', - 'enterprise_portal_email': 'Enterprise Portal Email', - } } diff --git a/ecommerce_worker/configuration/test.py b/ecommerce_worker/configuration/test.py index 646dbbc2..6a569eb1 100644 --- a/ecommerce_worker/configuration/test.py +++ b/ecommerce_worker/configuration/test.py @@ -32,27 +32,3 @@ # SITE-SPECIFIC CONFIGURATION OVERRIDES SITE_OVERRIDES = {} - -# Sailthru support unit test settings -SAILTHRU.update({ - 'SAILTHRU_ENABLE': True, - 'SAILTHRU_UPGRADE_TEMPLATE': 'upgrade_template', - 'SAILTHRU_PURCHASE_TEMPLATE': 'purchase_template', - 'SAILTHRU_ENROLL_TEMPLATE': 'enroll_template', - 'SAILTHRU_ABANDONED_CART_TEMPLATE': 'abandoned_template', - 'SAILTHRU_KEY': 'key', - 'SAILTHRU_SECRET': 'secret', -}) - -# Braze unit test settings -BRAZE.update({ - 'BRAZE_ENABLE': False, - 'BRAZE_REST_API_KEY': 'rest_api_key', - 'BRAZE_WEBAPP_API_KEY': 'webapp_api_key', -}) - -# Sailthru support unit test settings with override -TEST_SITE_OVERRIDES = { - 'test_site': { 'SAILTHRU': dict(SAILTHRU, - SAILTHRU_UPGRADE_TEMPLATE='site_upgrade_template')} -} diff --git a/ecommerce_worker/braze/__init__.py b/ecommerce_worker/email/__init__.py similarity index 100% rename from ecommerce_worker/braze/__init__.py rename to ecommerce_worker/email/__init__.py diff --git a/ecommerce_worker/braze/v1/__init__.py b/ecommerce_worker/email/v1/__init__.py similarity index 100% rename from ecommerce_worker/braze/v1/__init__.py rename to ecommerce_worker/email/v1/__init__.py diff --git a/ecommerce_worker/email/v1/api.py b/ecommerce_worker/email/v1/api.py new file mode 100644 index 00000000..b96906e5 --- /dev/null +++ b/ecommerce_worker/email/v1/api.py @@ -0,0 +1,17 @@ +"""Public API for ecommerce_worker.email.v1""" + +# Disable unused imports because these imports are meant for external consumption +# pylint: disable=unused-import + +# Celery tasks +from .tasks import ( + send_code_assignment_nudge_email, + send_offer_assignment_email, + send_offer_update_email, + send_offer_usage_email, +) + +# Normal helper methods +from .utils import ( + did_email_bounce, +) diff --git a/ecommerce_worker/braze/v1/tests/__init__.py b/ecommerce_worker/email/v1/braze/__init__.py similarity index 100% rename from ecommerce_worker/braze/v1/tests/__init__.py rename to ecommerce_worker/email/v1/braze/__init__.py diff --git a/ecommerce_worker/braze/v1/client.py b/ecommerce_worker/email/v1/braze/client.py similarity index 98% rename from ecommerce_worker/braze/v1/client.py rename to ecommerce_worker/email/v1/braze/client.py index 15df7e75..74b6d3fc 100644 --- a/ecommerce_worker/braze/v1/client.py +++ b/ecommerce_worker/email/v1/braze/client.py @@ -1,18 +1,15 @@ """ - Braze Client functions. +Braze Client functions. """ -from __future__ import absolute_import +from urllib.parse import urlencode, urljoin import copy import json import requests - -from urllib.parse import urlencode, urljoin - from celery.utils.log import get_task_logger -from ecommerce_worker.braze.v1.exceptions import ( +from ecommerce_worker.email.v1.braze.exceptions import ( ConfigurationError, BrazeNotEnabled, BrazeClientError, @@ -24,6 +21,11 @@ log = get_task_logger(__name__) +def is_braze_enabled(site_code) -> bool: + config = get_braze_configuration(site_code) + return bool(config.get('BRAZE_ENABLE')) + + def get_braze_configuration(site_code): """ Returns the Braze configuration for the specified site. diff --git a/ecommerce_worker/braze/v1/exceptions.py b/ecommerce_worker/email/v1/braze/exceptions.py similarity index 100% rename from ecommerce_worker/braze/v1/exceptions.py rename to ecommerce_worker/email/v1/braze/exceptions.py diff --git a/ecommerce_worker/email/v1/braze/tasks.py b/ecommerce_worker/email/v1/braze/tasks.py new file mode 100644 index 00000000..70028757 --- /dev/null +++ b/ecommerce_worker/email/v1/braze/tasks.py @@ -0,0 +1,154 @@ +""" +This file contains celery task functionality for braze. +""" + +from celery.utils.log import get_task_logger + +from ecommerce_worker.email.v1.braze.client import get_braze_client, get_braze_configuration +from ecommerce_worker.email.v1.braze.exceptions import BrazeError, BrazeRateLimitError, BrazeInternalServerError +from ecommerce_worker.email.v1.utils import update_assignment_email_status + +logger = get_task_logger(__name__) + + +def send_offer_assignment_email_via_braze(self, user_email, offer_assignment_id, subject, email_body, sender_alias, + site_code): + """ + Sends the offer assignment email via Braze. + + Args: + self: Ignore. + user_email (str): Recipient's email address. + offer_assignment_id (str): Key of the entry in the offer_assignment model. + subject (str): Email subject. + email_body (str): The body of the email. + sender_alias (str): Enterprise Customer sender alias used as From Name. + site_code (str): Identifier of the site sending the email. + base_enterprise_url (str): Url for the enterprise learner portal. + """ + config = get_braze_configuration(site_code) + try: + user_emails = [user_email] + braze_client = get_braze_client(site_code) + response = braze_client.send_message( + email_ids=user_emails, + subject=subject, + body=email_body, + sender_alias=sender_alias + ) + if response and response['success']: + dispatch_id = response['dispatch_id'] + if update_assignment_email_status(offer_assignment_id, dispatch_id, 'success'): + logger.info('[Offer Assignment] Offer assignment notification sent with message --- ' + '{message}'.format(message=email_body)) + else: + logger.exception( + '[Offer Assignment] An error occurred while updating email status data for ' + 'offer {token_offer} and email {token_email} via the ecommerce API.'.format( + token_offer=offer_assignment_id, + token_email=user_email, + ) + ) + except (BrazeRateLimitError, BrazeInternalServerError): + raise self.retry(countdown=config.get('BRAZE_RETRY_SECONDS'), + max_retries=config.get('BRAZE_RETRY_ATTEMPTS')) + except BrazeError: + logger.exception( + ('[Offer Assignment] Error in offer assignment notification with message --- ' + '{message}'.format(message=email_body) + ) + ) + + +def send_offer_update_email_via_braze(self, user_email, subject, email_body, sender_alias, site_code): + """ + Sends the offer emails after assignment via braze, either for revoking or reminding. + + Args: + self: Ignore. + user_email (str): Recipient's email address. + subject (str): Email subject. + email_body (str): The body of the email. + site_code (str): Identifier of the site sending the email. + sender_alias (str): Enterprise Customer sender alias used as From Name. + """ + config = get_braze_configuration(site_code) + try: + user_emails = [user_email] + braze_client = get_braze_client(site_code) + braze_client.send_message( + email_ids=user_emails, + subject=subject, + body=email_body, + sender_alias=sender_alias + ) + except (BrazeRateLimitError, BrazeInternalServerError): + raise self.retry(countdown=config.get('BRAZE_RETRY_SECONDS'), + max_retries=config.get('BRAZE_RETRY_ATTEMPTS')) + except BrazeError: + logger.exception( + '[Offer Assignment] Error in offer update notification with message --- ' + '{message}'.format(message=email_body) + ) + + +def send_offer_usage_email_via_braze(self, emails, subject, email_body, site_code): + """ + Sends the offer usage email via braze. + + Args: + self: Ignore. + emails (str): comma separated emails. + subject (str): Email subject. + email_body (str): The body of the email. + site_code (str): Identifier of the site sending the email. + """ + config = get_braze_configuration(site_code) + try: + user_emails = list(emails.strip().split(",")) + braze_client = get_braze_client(site_code) + braze_client.send_message( + email_ids=user_emails, + subject=subject, + body=email_body + ) + except (BrazeRateLimitError, BrazeInternalServerError): + raise self.retry(countdown=config.get('BRAZE_RETRY_SECONDS'), + max_retries=config.get('BRAZE_RETRY_ATTEMPTS')) + except BrazeError: + logger.exception( + '[Offer Usage] Error in offer usage notification with message --- ' + '{message}'.format(message=email_body) + ) + + +def send_code_assignment_nudge_email_via_braze(self, email, subject, email_body, sender_alias, site_code): + """ + Sends the code assignment nudge email via braze. + + Args: + self: Ignore. + email (str): Recipient's email address. + subject (str): Email subject. + email_body (str): The body of the email. + sender_alias (str): Enterprise Customer sender alias used as From Name. + site_code (str): Identifier of the site sending the email. + """ + config = get_braze_configuration(site_code) + try: + user_emails = [email] + braze_client = get_braze_client(site_code) + braze_client.send_message( + email_ids=user_emails, + subject=subject, + body=email_body, + sender_alias=sender_alias + ) + except (BrazeRateLimitError, BrazeInternalServerError): + raise self.retry(countdown=config.get('BRAZE_RETRY_SECONDS'), + max_retries=config.get('BRAZE_RETRY_ATTEMPTS')) + except BrazeError: + logger.exception( + '[Code Assignment Nudge Email] Error in offer nudge notification with message --- ' + '{message}'.format(message=email_body) + ) diff --git a/ecommerce_worker/sailthru/__init__.py b/ecommerce_worker/email/v1/braze/tests/__init__.py similarity index 100% rename from ecommerce_worker/sailthru/__init__.py rename to ecommerce_worker/email/v1/braze/tests/__init__.py diff --git a/ecommerce_worker/braze/v1/tests/test_client.py b/ecommerce_worker/email/v1/braze/tests/test_client.py similarity index 88% rename from ecommerce_worker/braze/v1/tests/test_client.py rename to ecommerce_worker/email/v1/braze/tests/test_client.py index 31b18ebb..9c4b576a 100644 --- a/ecommerce_worker/braze/v1/tests/test_client.py +++ b/ecommerce_worker/email/v1/braze/tests/test_client.py @@ -2,18 +2,16 @@ Tests for Braze client. """ -from __future__ import absolute_import from unittest import TestCase -import ddt +from urllib.parse import urlencode +import ddt import mock import responses - -from urllib.parse import urlencode - from mock import patch, Mock -from ecommerce_worker.braze.v1.client import get_braze_client -from ecommerce_worker.braze.v1.exceptions import ( + +from ecommerce_worker.email.v1.braze.client import get_braze_client +from ecommerce_worker.email.v1.braze.exceptions import ( BrazeClientError, BrazeNotEnabled, BrazeRateLimitError, @@ -76,7 +74,7 @@ def assert_get_braze_client_raises(self, exc_class, config): """ Asserts an error is raised by a call to get_braze_client. """ - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=config)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=config)): with self.assertRaises(exc_class): get_braze_client(self.SITE_CODE) @@ -85,7 +83,7 @@ def test_get_braze_client_with_braze_disabled(self): Verify the method raises a BrazeNotEnabled if Braze is not enabled for the site. """ - with mock.patch('ecommerce_worker.braze.v1.client.log.debug') as mock_log: + with mock.patch('ecommerce_worker.email.v1.braze.client.log.debug') as mock_log: self.assert_get_braze_client_raises(BrazeNotEnabled, {'BRAZE_ENABLE': False}) mock_log.assert_called_once_with('Braze is not enabled for site {}'.format(self.SITE_CODE)) @@ -101,7 +99,7 @@ def test_get_braze_client_without_credentials(self, braze_config): """ braze_config['BRAZE_ENABLE'] = True - with mock.patch('ecommerce_worker.braze.v1.client.log.error') as mock_log: + with mock.patch('ecommerce_worker.email.v1.braze.client.log.error') as mock_log: self.assert_get_braze_client_raises(ConfigurationError, braze_config) mock_log.assert_called_once_with('Required keys missing for site {}'.format(self.SITE_CODE)) @@ -111,7 +109,7 @@ def test_create_braze_alias(self): Asserts an error is raised by a call to create_braze_alias. """ braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) with self.assertRaises(BrazeClientError): client.create_braze_alias(recipient_emails=[]) @@ -136,7 +134,7 @@ def test_send_braze_message_success(self): status=201 ) braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) response = client.send_message( ['test1@example.com', 'test2@example.com'], @@ -165,7 +163,7 @@ def test_send_braze_message_success_with_external_ids(self): status=201 ) braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) response = client.send_message( ['test1@example.com', 'test2@example.com'], @@ -199,7 +197,7 @@ def test_send_braze_message_failure(self, status_code, error): status=status_code ) braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) with self.assertRaises(error): response = client.send_message( @@ -219,7 +217,7 @@ def test_send_braze_message_failure_missing_parameters(self, email, subject, bod Verify that an error is raised for missing email parameters. """ braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) with self.assertRaises(BrazeClientError): response = client.send_message(email, subject, body) @@ -244,7 +242,7 @@ def test_send_braze_campaign_message_success(self): status=201 ) braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) response = client.send_campaign_message( ['test1@example.com', 'test2@example.com'], @@ -278,7 +276,7 @@ def test_send_braze_campaign_message_failure(self, status_code, error): status=status_code ) braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) with self.assertRaises(error): response = client.send_campaign_message( @@ -298,7 +296,7 @@ def test_send_braze_campaign_message_failure_missing_parameters(self, email, sub Verify that an error is raised for missing email parameters. """ braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) with self.assertRaises(BrazeClientError): response = client.send_campaign_message(email, subject, body) @@ -342,7 +340,7 @@ def test_bounced_email(self, response, did_bounce): status=200 ) braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) bounce = client.did_email_bounce(bounced_email) self.assertEqual(bounce, did_bounce) @@ -373,7 +371,7 @@ def test_bounce_message_failure(self, status_code, error): status=status_code ) braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) with self.assertRaises(error): client.did_email_bounce(bounced_email) @@ -383,7 +381,7 @@ def test_did_email_bounce_failure_missing_parameters(self): Verify that an error is raised for missing parameters. """ braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) with self.assertRaises(BrazeClientError): client.did_email_bounce(email_id=None) @@ -393,7 +391,7 @@ def test_get_braze_external_id(self): Asserts an error is raised by a call to get_braze_external_id. """ braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) with self.assertRaises(BrazeClientError): client.get_braze_external_id(email_id=None) @@ -411,7 +409,7 @@ def test_get_braze_external_id_success(self): status=201 ) braze = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=braze)): + with patch('ecommerce_worker.email.v1.braze.client.get_braze_configuration', Mock(return_value=braze)): client = get_braze_client(self.SITE_CODE) external_id = client.get_braze_external_id(email_id='test@example.com') self.assertEqual(external_id, '5261613') diff --git a/ecommerce_worker/email/v1/braze/tests/test_tasks.py b/ecommerce_worker/email/v1/braze/tests/test_tasks.py new file mode 100644 index 00000000..8307f154 --- /dev/null +++ b/ecommerce_worker/email/v1/braze/tests/test_tasks.py @@ -0,0 +1,304 @@ +"""Tests of Braze task code.""" + +import logging +from unittest import TestCase + +import ddt +import responses +from celery.exceptions import Retry +from mock import patch, Mock +from requests.exceptions import RequestException +from testfixtures import LogCapture + +from ecommerce_worker.email.v1.braze.exceptions import ( + BrazeError, + BrazeInternalServerError, + BrazeRateLimitError +) +from ecommerce_worker.email.v1.tasks import ( + send_code_assignment_nudge_email, + send_offer_assignment_email, + send_offer_update_email, + send_offer_usage_email, +) + + +@ddt.ddt +class SendEmailsViaBrazeTests(TestCase): + """ Validates the email sending tasks with Braze api. """ + LOG_NAME = 'ecommerce_worker.email.v1.braze.tasks' + LOG_TASK_NAME = 'ecommerce_worker.email.v1.braze.tasks' + USER_EMAIL = 'user@unknown.com' + EMAILS = 'user@unknown.com, user1@example.com' + OFFER_ASSIGNMENT_ID = '555' + SEND_ID = '66cdc28f8f082bc3074c0c79f' + SUBJECT = 'New edX course assignment' + EMAIL_BODY = 'Template message with johndoe@unknown.com GIL7RUEOU7VHBH7Q ' \ + 'https://tempurl.url/enroll 3 2012-04-23' + SENDER_ALIAS = 'edx Support Team' + SITE_CODE = 'test' + BRAZE_CONFIG = { + 'BRAZE_ENABLE': True, + 'BRAZE_REST_API_KEY': 'rest_api_key', + 'BRAZE_WEBAPP_API_KEY': 'webapp_api_key', + 'REST_API_URL': 'https://rest.iad-06.braze.com', + 'MESSAGES_SEND_ENDPOINT': '/messages/send', + 'EMAIL_BOUNCE_ENDPOINT': '/email/hard_bounces', + 'NEW_ALIAS_ENDPOINT': '/users/alias/new', + 'USERS_TRACK_ENDPOINT': '/users/track', + 'EXPORT_ID_ENDPOINT': '/users/export/ids', + 'CAMPAIGN_SEND_ENDPOINT': '/campaigns/trigger/send', + 'ENTERPRISE_CAMPAIGN_ID': '', + 'FROM_EMAIL': '', + 'BRAZE_RETRY_SECONDS': 3600, + 'BRAZE_RETRY_ATTEMPTS': 6, + } + + ASSIGNMENT_TASK_KWARGS = { + 'user_email': USER_EMAIL, + 'offer_assignment_id': OFFER_ASSIGNMENT_ID, + 'subject': SUBJECT, + 'email_body': EMAIL_BODY, + 'sender_alias': SENDER_ALIAS, + 'site_code': SITE_CODE, + } + + UPDATE_TASK_KWARGS = { + 'user_email': USER_EMAIL, + 'subject': SUBJECT, + 'email_body': EMAIL_BODY, + 'sender_alias': SENDER_ALIAS, + 'site_code': SITE_CODE, + } + + USAGE_TASK_KWARGS = { + 'emails': EMAILS, + 'subject': SUBJECT, + 'email_body': EMAIL_BODY, + 'site_code': SITE_CODE, + } + + NUDGE_TASK_KWARGS = { + 'email': USER_EMAIL, + 'subject': SUBJECT, + 'email_body': EMAIL_BODY, + 'sender_alias': SENDER_ALIAS, + 'site_code': SITE_CODE, + } + + def execute_task(self): + """ Execute the send_offer_assignment_email task. """ + with patch('ecommerce_worker.configuration.test.BRAZE', self.BRAZE_CONFIG): + send_offer_assignment_email(**self.ASSIGNMENT_TASK_KWARGS) + + def mock_braze_user_endpoints(self): + """ Mock POST requests to the user alias and track endpoints. """ + host = 'https://rest.iad-06.braze.com/users/track' + responses.add( + responses.POST, + host, + json={'message': 'success'}, + status=201 + ) + host = 'https://rest.iad-06.braze.com/users/alias/new' + responses.add( + responses.POST, + host, + json={'message': 'success'}, + status=201 + ) + host = 'https://rest.iad-06.braze.com/users/export/ids' + responses.add( + responses.POST, + host, + json={"users": [], "message": "success"}, + status=201 + ) + + @patch('ecommerce_worker.email.v1.braze.tasks.get_braze_client', Mock(side_effect=BrazeError)) + @ddt.data( + (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS, "Offer Assignment", 'assignment'), + (send_offer_update_email, UPDATE_TASK_KWARGS, "Offer Assignment", 'update'), + (send_offer_usage_email, USAGE_TASK_KWARGS, "Offer Usage", 'usage'), + (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS, "Code Assignment Nudge Email", 'nudge'), + ) + @ddt.unpack + def test_client_instantiation_error(self, task, task_kwargs, logger_prefix, log_message): + """ Verify no message is sent if an error occurs while instantiating the Braze API client. """ + with patch('ecommerce_worker.configuration.test.BRAZE', self.BRAZE_CONFIG): + with LogCapture(level=logging.INFO) as log: + task(**task_kwargs) + log.check( + ( + self.LOG_NAME, + 'ERROR', + '[{logger_prefix}] Error in offer {log_message} notification with message --- ' + '{message}'.format( + logger_prefix=logger_prefix, + log_message=log_message, + message=self.EMAIL_BODY + ) + ), + ) + + @patch('ecommerce_worker.email.v1.braze.tasks.logger.exception') + @ddt.data( + (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS, "Offer Assignment", 'assignment'), + (send_offer_update_email, UPDATE_TASK_KWARGS, "Offer Assignment", 'update'), + (send_offer_usage_email, USAGE_TASK_KWARGS, "Offer Usage", 'usage'), + (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS, "Code Assignment Nudge Email", 'nudge'), + ) + @ddt.unpack + def test_api_client_error(self, task, task_kwargs, logger_prefix, log_message, mock_log): + """ Verify API client errors are logged. """ + with patch('ecommerce_worker.configuration.test.BRAZE', self.BRAZE_CONFIG): + task(**task_kwargs) + mock_log.assert_called_once_with( + '[{logger_prefix}] Error in offer {log_message} notification with message --- ' + '{message}'.format( + logger_prefix=logger_prefix, + log_message=log_message, + message=self.EMAIL_BODY + ) + ) + + @responses.activate + @ddt.data( + (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS), + (send_offer_update_email, UPDATE_TASK_KWARGS), + (send_offer_usage_email, USAGE_TASK_KWARGS), + (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS), + ) + @ddt.unpack + def test_api_429_error_with_retry(self, task, task_kwargs): + """ Verify the task is rescheduled if an API error occurs, and the request can be retried. """ + self.mock_braze_user_endpoints() + failure_response = { + 'message': 'Not a Success', + 'status_code': 429 + } + host = 'https://rest.iad-06.braze.com/messages/send' + responses.add( + responses.POST, + host, + json=failure_response, + status=429 + ) + with patch('ecommerce_worker.configuration.test.BRAZE', self.BRAZE_CONFIG): + with self.assertRaises((BrazeRateLimitError, Retry)): + task(**task_kwargs) + + @responses.activate + @ddt.data( + (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS), + (send_offer_update_email, UPDATE_TASK_KWARGS), + (send_offer_usage_email, USAGE_TASK_KWARGS), + (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS), + ) + @ddt.unpack + def test_api_500_error_with_retry(self, task, task_kwargs): + """ Verify 500 error triggers a request retry. """ + self.mock_braze_user_endpoints() + failure_response = { + 'message': 'Not a Success', + 'status_code': 500 + } + host = 'https://rest.iad-06.braze.com/messages/send' + responses.add( + responses.POST, + host, + json=failure_response, + status=500 + ) + with patch('ecommerce_worker.configuration.test.BRAZE', self.BRAZE_CONFIG): + with self.assertRaises((BrazeInternalServerError, Retry)): + task(**task_kwargs) + + @responses.activate + @ddt.data( + (send_offer_update_email, UPDATE_TASK_KWARGS), + (send_offer_usage_email, USAGE_TASK_KWARGS), + (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS), + ) + @ddt.unpack + def test_api(self, task, task_kwargs): + """ + Test the happy path. + """ + self.mock_braze_user_endpoints() + success_response = { + "dispatch_id": "66cdc28f8f082bc3074c0c79f", + "errors": [], + "message": "success", + "status_code": 201 + } + host = 'https://rest.iad-06.braze.com/messages/send' + responses.add( + responses.POST, + host, + json=success_response, + status=201) + with patch('ecommerce_worker.configuration.test.BRAZE', self.BRAZE_CONFIG): + task(**task_kwargs) + self.assertTrue('success' in responses.calls[0].response.text) + + @responses.activate + @patch('ecommerce_worker.email.v1.braze.tasks.update_assignment_email_status') + def test_message_sent(self, mock_update_assignment): + """ Verify a message is logged after a successful API call to send the message. """ + self.mock_braze_user_endpoints() + success_response = { + 'dispatch_id': '66cdc28f8f082bc3074c0c79f', + 'errors': [], + 'message': 'success', + 'status_code': 201 + } + host = 'https://rest.iad-06.braze.com/messages/send' + responses.add( + responses.POST, + host, + json=success_response, + status=201) + with LogCapture(level=logging.INFO) as log: + self.execute_task() + log.check_present( + ( + self.LOG_TASK_NAME, + 'INFO', + '[Offer Assignment] Offer assignment notification sent with message --- ' + '{message}'.format(message=self.EMAIL_BODY) + ), + ) + self.assertEqual(mock_update_assignment.call_count, 1) + + @responses.activate + @patch('ecommerce_worker.email.v1.utils.get_ecommerce_client') + def test_update_assignment_exception(self, mock_get_ecommerce_client): + """ Verify a message is logged after an unsuccessful API call to update the status. """ + self.mock_braze_user_endpoints() + success_response = { + 'dispatch_id': '66cdc28f8f082bc3074c0c79f', + 'errors': [], + 'message': 'success', + 'status_code': 201 + } + host = 'https://rest.iad-06.braze.com/messages/send' + responses.add( + responses.POST, + host, + json=success_response, + status=201) + mock_get_ecommerce_client.status.side_effect = RequestException + with LogCapture(level=logging.INFO) as log: + self.execute_task() + log.check_present( + ( + self.LOG_TASK_NAME, + 'ERROR', + '[Offer Assignment] An error occurred while updating email status data for offer {token_offer} and ' + 'email {token_email} via the ecommerce API.'.format( + token_offer=self.OFFER_ASSIGNMENT_ID, + token_email=self.USER_EMAIL + ) + ) + ) diff --git a/ecommerce_worker/email/v1/tasks.py b/ecommerce_worker/email/v1/tasks.py new file mode 100644 index 00000000..b65ee48d --- /dev/null +++ b/ecommerce_worker/email/v1/tasks.py @@ -0,0 +1,95 @@ +""" +This file contains celery tasks for email marketing signal handler. +""" + +from celery import shared_task + +from ecommerce_worker.email.v1.braze.client import is_braze_enabled +from ecommerce_worker.email.v1.braze.tasks import ( + send_code_assignment_nudge_email_via_braze, + send_offer_assignment_email_via_braze, + send_offer_update_email_via_braze, + send_offer_usage_email_via_braze, +) + +# Disable unused imports because these are public API functions and previous implementations used some of these +# arguments, but current ones don't use all of them. And maybe future implementations will use them again. But +# regardless, they are intentionally there as part of the public API, to be used or not by implementations. +# pylint: disable=unused-argument + + +@shared_task(bind=True, ignore_result=True) +def send_offer_assignment_email(self, user_email, offer_assignment_id, subject, email_body, sender_alias, + site_code=None, base_enterprise_url='') -> None: + """ + Sends the offer assignment email. + + Args: + self: Ignore. + user_email (str): Recipient's email address. + offer_assignment_id (str): Key of the entry in the offer_assignment model. + subject (str): Email subject. + email_body (str): The body of the email. + site_code (str): Identifier of the site sending the email. + base_enterprise_url (str): Url for the enterprise learner portal. + sender_alias (str): Enterprise Customer sender alias used as From Name. + """ + if is_braze_enabled(site_code): + send_offer_assignment_email_via_braze( + self, user_email, offer_assignment_id, subject, email_body, sender_alias, site_code) + + +@shared_task(bind=True, ignore_result=True) +def send_offer_update_email(self, user_email, subject, email_body, sender_alias, site_code=None, + base_enterprise_url='') -> None: + """ + Sends the offer emails after assignment, either for revoking or reminding. + + Args: + self: Ignore. + user_email (str): Recipient's email address. + subject (str): Email subject. + email_body (str): The body of the email. + site_code (str): Identifier of the site sending the email. + sender_alias (str): Enterprise Customer sender alias used as From Name. + base_enterprise_url (str): Enterprise learner portal url. + """ + if is_braze_enabled(site_code): + send_offer_update_email_via_braze(self, user_email, subject, email_body, sender_alias, site_code) + + +@shared_task(bind=True, ignore_result=True) +def send_offer_usage_email(self, emails, subject, email_body, site_code=None, + base_enterprise_url='') -> None: + """ + Sends the offer usage email. + + Args: + self: Ignore. + emails (str): comma separated emails. + subject (str): Email subject. + email_body (str): The body of the email. + site_code (str): Identifier of the site sending the email. + base_enterprise_url (str): Url of the enterprise's learner portal + """ + if is_braze_enabled(site_code): + send_offer_usage_email_via_braze(self, emails, subject, email_body, site_code) + + +@shared_task(bind=True, ignore_result=True) +def send_code_assignment_nudge_email(self, email, subject, email_body, sender_alias, site_code=None, + base_enterprise_url='') -> None: + """ + Sends the code assignment nudge email. + + Args: + self: Ignore. + email (str): Recipient's email address. + subject (str): Email subject. + email_body (str): The body of the email. + sender_alias (str): Enterprise Customer sender alias used as From Name. + site_code (str): Identifier of the site sending the email. + base_enterprise_url (str): Enterprise learner portal url. + """ + if is_braze_enabled(site_code): + send_code_assignment_nudge_email_via_braze(self, email, subject, email_body, sender_alias, site_code) diff --git a/ecommerce_worker/sailthru/v1/__init__.py b/ecommerce_worker/email/v1/tests/__init__.py similarity index 100% rename from ecommerce_worker/sailthru/v1/__init__.py rename to ecommerce_worker/email/v1/tests/__init__.py diff --git a/ecommerce_worker/email/v1/tests/test_api.py b/ecommerce_worker/email/v1/tests/test_api.py new file mode 100644 index 00000000..155d184a --- /dev/null +++ b/ecommerce_worker/email/v1/tests/test_api.py @@ -0,0 +1,22 @@ +""" Tests for api entry point. """ + +from inspect import getmembers +from unittest import TestCase + +from ecommerce_worker.email.v1 import api + + +class EmailApiTests(TestCase): + """Tests for email v1 api entry point.""" + + def test_public_api(self): + """ + Test that we don't accidentally expose anything we don't expect to + """ + assert [k for k, v in getmembers(api) if not k.startswith('_')] == [ + 'did_email_bounce', + 'send_code_assignment_nudge_email', + 'send_offer_assignment_email', + 'send_offer_update_email', + 'send_offer_usage_email', + ] diff --git a/ecommerce_worker/email/v1/tests/test_utils.py b/ecommerce_worker/email/v1/tests/test_utils.py new file mode 100644 index 00000000..92bdfd24 --- /dev/null +++ b/ecommerce_worker/email/v1/tests/test_utils.py @@ -0,0 +1,80 @@ +""" Tests for utility functions. """ + +from unittest import TestCase + +import ddt +import json +import responses +from mock import patch + +from ecommerce_worker.email.v1.utils import did_email_bounce, update_assignment_email_status +from ecommerce_worker.utils import get_configuration + + +@ddt.ddt +class EmailUtilsTests(TestCase): + """Tests for email v1 utility functions.""" + + def mock_ecommerce_assignment_email_api(self, body, status=200): + """ Mock POST requests to the ecommerce assignment-email API endpoint. """ + responses.reset() + responses.add( + responses.POST, '{}/assignment-email/status/'.format( + get_configuration('ECOMMERCE_API_ROOT').strip('/') + ), + status=status, + body=json.dumps(body), content_type='application/json', + ) + + @responses.activate + @ddt.data( + ( + # Success case + { + 'offer_assignment_id': '555', + 'send_id': '1234ABC', + 'status': 'updated', + 'error': '' + }, + True, + ), + ( + # Exception case + { + 'offer_assignment_id': '555', + 'send_id': '1234ABC', + 'status': 'failed', + 'error': '' + }, + False, + ), + ) + @ddt.unpack + def test_update_assignment_email_status(self, data, return_value): + """ + Test routine that updates email send status in ecommerce. + """ + self.mock_ecommerce_assignment_email_api(data) + self.assertEqual( + update_assignment_email_status('555', '1234ABC', 'success'), + return_value + ) + + def test_update_assignment_email_exception(self): + """ + Test that we gracefully catch a request exception. + """ + # Do not mock api response - responses module will reject http request + assert not update_assignment_email_status('555', '1234ABC', 'success') + + @patch('ecommerce_worker.email.v1.utils.get_braze_client') + @patch('ecommerce_worker.email.v1.utils.is_braze_enabled') + @ddt.data(True, False) + def test_did_email_bounce_routing(self, braze_enabled, braze_enabled_mock, braze_client_mock): + """ + Test that we direct requests for email bouncing correctly. + """ + braze_enabled_mock.return_value = braze_enabled + braze_client_mock.return_value.did_email_bounce.return_value = True + bounced = did_email_bounce('email', 'site_code') + assert bounced == braze_enabled diff --git a/ecommerce_worker/email/v1/utils.py b/ecommerce_worker/email/v1/utils.py new file mode 100644 index 00000000..fd75bca1 --- /dev/null +++ b/ecommerce_worker/email/v1/utils.py @@ -0,0 +1,45 @@ +""" Utility functions. """ + +from requests.exceptions import RequestException + +from ecommerce_worker.email.v1.braze.client import is_braze_enabled, get_braze_client +from ecommerce_worker.utils import get_ecommerce_client + + +def update_assignment_email_status(offer_assignment_id, send_id, status, site_code=None): + """ + Update the offer_assignment and offer_assignment_email model using the Ecommerce assignment-email api. + Arguments: + offer_assignment_id (str): Key of the entry in the offer_assignment model. + send_id (str): Unique message id from Sailthru + status (str): status to be sent to the api + site_code (str): site code + Returns: + True or False based on model update status from Ecommerce api + """ + api = get_ecommerce_client(url_postfix='assignment-email/', site_code=site_code) + post_data = { + 'offer_assignment_id': offer_assignment_id, + 'send_id': send_id, + 'status': status, + } + try: + api_response = api.status().post(post_data) + except RequestException: + return False + return bool(api_response.get('status') == 'updated') + + +def did_email_bounce(user_email, site_code=None) -> bool: + """ + Checks if the given user's emails have bounced. + + Args: + user_email (str): Recipient's email address. + site_code (str): Identifier of the site sending the email. + """ + if is_braze_enabled(): + client = get_braze_client(site_code) + return client.did_email_bounce(user_email) + + return False diff --git a/ecommerce_worker/fulfillment/v1/tests/test_tasks.py b/ecommerce_worker/fulfillment/v1/tests/test_tasks.py index a07b26ba..b88c1fce 100644 --- a/ecommerce_worker/fulfillment/v1/tests/test_tasks.py +++ b/ecommerce_worker/fulfillment/v1/tests/test_tasks.py @@ -49,7 +49,7 @@ def test_fulfillment_success(self): # Validate the value of the HTTP Authorization header. last_request = responses.calls[-1].request token = last_request.headers.get('authorization').split()[1] - payload = jwt.decode(token, get_configuration('JWT_SECRET_KEY')) + payload = jwt.decode(token, get_configuration('JWT_SECRET_KEY'), algorithms=['HS256']) self.assertEqual(payload['username'], get_configuration('ECOMMERCE_SERVICE_USERNAME')) @responses.activate diff --git a/ecommerce_worker/sailthru/v1/constants.py b/ecommerce_worker/sailthru/v1/constants.py deleted file mode 100644 index 00935d1a..00000000 --- a/ecommerce_worker/sailthru/v1/constants.py +++ /dev/null @@ -1 +0,0 @@ -BASE_ENTERPRISE_URL_VAR_NAME='base_enterprise_url' diff --git a/ecommerce_worker/sailthru/v1/exceptions.py b/ecommerce_worker/sailthru/v1/exceptions.py deleted file mode 100644 index 4fcd3584..00000000 --- a/ecommerce_worker/sailthru/v1/exceptions.py +++ /dev/null @@ -1,13 +0,0 @@ -""" Sailthru-related exception classes. """ - - -class SailthruError(Exception): - """ Base class for Sailthru-related errors. """ - - -class ConfigurationError(SailthruError): - """ Raised when Sailthru is not properly configured. """ - - -class SailthruNotEnabled(SailthruError): - """ Raised when Sailthru is not enabled. """ diff --git a/ecommerce_worker/sailthru/v1/notification.py b/ecommerce_worker/sailthru/v1/notification.py deleted file mode 100644 index ca94ed14..00000000 --- a/ecommerce_worker/sailthru/v1/notification.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -Notification class for sending the notification emails. -""" -from __future__ import absolute_import -from celery.utils.log import get_task_logger - -from sailthru.sailthru_error import SailthruClientError -from ecommerce_worker.sailthru.v1.exceptions import SailthruError -from ecommerce_worker.sailthru.v1.utils import can_retry_sailthru_request, get_sailthru_client - -log = get_task_logger(__name__) - - -class Notification: - """ - This class exports the 'send' function for sending the emails. - """ - - def __init__(self, config, emails, email_vars, logger_prefix, site_code, template): - self.config = config - self.emails = emails - self.email_vars = email_vars - self.logger_prefix = logger_prefix - self.site_code = site_code - self.template = template - - def _is_eligible_for_retry(self, response): - """ - Return a bool whether this task is eligible for retry or not. - Also log the appropriate message according to occurred error. - """ - is_eligible_for_retry = False - error = response.get_error() - log.error( - '[{logger_prefix}] An error occurred while attempting to send a notification. Message: {message},' - ' Error code: {token_error_code}, Error Message: {token_error_message}.'.format( - logger_prefix=self.logger_prefix, - message=self.email_vars.get('email_body'), - token_error_code=error.get_error_code(), - token_error_message=error.get_message() - ) - ) - if can_retry_sailthru_request(error): - log.info( - '[{logger_prefix}] An attempt will be made to resend the notification.' - ' Message: {message}'.format( - logger_prefix=self.logger_prefix, - message=self.email_vars.get('email_body') - ) - ) - is_eligible_for_retry = True - else: - log.warning( - '[{logger_prefix}] No further attempts will be made to send the notification.' - ' Failed Message: {message}'.format( - logger_prefix=self.logger_prefix, - message=self.email_vars.get('email_body') - ) - ) - return is_eligible_for_retry - - @staticmethod - def _get_send_callback(is_multi_send): - """ - Return the associated function 'send' in case of single send and 'multi_send' in case of multi_send - """ - return 'multi_send' if is_multi_send else 'send' - - def _get_client(self): - """ - Return the sailthru client or None if exception raised. - """ - sailthru_client = None - try: - sailthru_client = get_sailthru_client(self.site_code) - except SailthruError: - log.exception( - '[{logger_prefix}] A client error occurred while attempting to send a notification.' - ' Message: {message}'.format( - logger_prefix=self.logger_prefix, - message=self.email_vars.get('email_body') - ) - ) - return sailthru_client - - def _get_params(self, is_multi_send): - """ - Return the dict of parameters according to the given 'is_multi_send' parameter. - - It can be a simple 'send' function in which we will send an email - to a single email address or it can be a 'multi_send' function. - """ - params = { - 'template': self.config['templates'][self.template], - '_vars': self.email_vars - } - email_param = 'emails' if is_multi_send else 'email' - params[email_param] = self.emails - return params - - def send(self, is_multi_send=False): - """ - Send the notification email to single email address or comma - separated emails on bases of is_multi_send parameter - - Returns: - response: Sailthru endpoint response. - is_eligible_for_retry(Bool): whether this response is eligible for retry or not. - """ - is_eligible_for_retry = False - sailthru_client = self._get_client() - if sailthru_client is None: - return None, is_eligible_for_retry - - try: - response = getattr( - sailthru_client, - self._get_send_callback(is_multi_send) - )(**self._get_params(is_multi_send)) - except SailthruClientError: - log.exception( - '[{logger_prefix}] A client error occurred while attempting to send a notification.' - ' Message: {message}'.format( - logger_prefix=self.logger_prefix, - message=self.email_vars.get('email_body') - ) - ) - return None, is_eligible_for_retry - - if response.is_ok(): - log.info( - '[{logger_prefix}] A notification has been sent successfully. Message: {message}'.format( - logger_prefix=self.logger_prefix, - message=self.email_vars.get('email_body') - ) - ) - else: - is_eligible_for_retry = self._is_eligible_for_retry(response) - return response, is_eligible_for_retry diff --git a/ecommerce_worker/sailthru/v1/tasks.py b/ecommerce_worker/sailthru/v1/tasks.py deleted file mode 100644 index 8687f80c..00000000 --- a/ecommerce_worker/sailthru/v1/tasks.py +++ /dev/null @@ -1,658 +0,0 @@ -""" -This file contains celery tasks for email marketing signal handler. -""" - -from __future__ import absolute_import -from decimal import Decimal - -from celery import shared_task -from celery.utils.log import get_task_logger -from sailthru.sailthru_error import SailthruClientError - -from requests.exceptions import RequestException -from six import text_type - -from ecommerce_worker.cache import Cache -from ecommerce_worker.braze.v1.client import get_braze_client, get_braze_configuration -from ecommerce_worker.braze.v1.exceptions import BrazeError, BrazeRateLimitError, BrazeInternalServerError -from ecommerce_worker.sailthru.v1.exceptions import SailthruError -from ecommerce_worker.sailthru.v1.notification import Notification -from ecommerce_worker.sailthru.v1.constants import BASE_ENTERPRISE_URL_VAR_NAME -from ecommerce_worker.sailthru.v1.utils import ( - can_retry_sailthru_request, - get_sailthru_client, - get_sailthru_configuration -) -from ecommerce_worker.utils import get_ecommerce_client - -logger = get_task_logger(__name__) # pylint: disable=invalid-name -cache = Cache() # pylint: disable=invalid-name - - -# pylint: disable=unused-argument - -def schedule_retry(self, config): - """Schedule a retry""" - raise self.retry(countdown=config.get('SAILTHRU_RETRY_SECONDS'), - max_retries=config.get('SAILTHRU_RETRY_ATTEMPTS')) - - -def _build_purchase_item(course_id, course_url, cost_in_cents, mode, course_data, sku): - """Build and return Sailthru purchase item object""" - - # build item description - item = { - 'id': "{}-{}".format(course_id, mode), - 'url': course_url, - 'price': cost_in_cents, - 'qty': 1, - } - - # get title from course info if we don't already have it from Sailthru - if 'title' in course_data: - item['title'] = course_data['title'] - else: - # can't find, just invent title - item['title'] = 'Course {} mode: {}'.format(course_id, mode) - - if 'tags' in course_data: - item['tags'] = course_data['tags'] - - # add vars to item - item['vars'] = dict(course_data.get('vars', {}), mode=mode, course_run_id=course_id) - - item['vars']['purchase_sku'] = sku - - return item - - -def _record_purchase(sailthru_client, email, item, purchase_incomplete, message_id, options): - """Record a purchase in Sailthru - - Arguments: - sailthru_client (object): SailthruClient - email (str): user's email address - item (dict): Sailthru required information about the course - purchase_incomplete (boolean): True if adding item to shopping cart - message_id (str): Cookie used to identify marketing campaign - options (dict): Sailthru purchase API options (e.g. template name) - - Returns: - False if retryable error, else True - """ - try: - sailthru_response = sailthru_client.purchase(email, [item], - incomplete=purchase_incomplete, message_id=message_id, - options=options) - - if not sailthru_response.is_ok(): - error = sailthru_response.get_error() - logger.error("Error attempting to record purchase in Sailthru: %s", error.get_message()) - return not can_retry_sailthru_request(error) - - except SailthruClientError as exc: - logger.exception("Exception attempting to record purchase for %s in Sailthru - %s", email, text_type(exc)) - return False - - return True - - -def _get_course_content(course_id, course_url, sailthru_client, site_code, config): - """Get course information using the Sailthru content api or from cache. - - If there is an error, just return with an empty response. - - Arguments: - course_id (str): course key of the course - course_url (str): LMS url for course info page. - sailthru_client (object): SailthruClient - site_code (str): site code - config (dict): config options - - Returns: - course information from Sailthru - """ - # check cache first - cache_key = "{}:{}".format(site_code, course_url) - response = cache.get(cache_key) - if not response: - try: - sailthru_response = sailthru_client.api_get("content", {"id": course_url}) - if not sailthru_response.is_ok(): - response = {} - else: - response = sailthru_response.json - cache.set(cache_key, response, config.get('SAILTHRU_CACHE_TTL_SECONDS')) - - except SailthruClientError: - response = {} - - if not response: - logger.error('Could not get course data from Sailthru on enroll/purchase event. ' - 'Calling Ecommerce Course API to get course info for enrollment confirmation email') - response = _get_course_content_from_ecommerce(course_id, site_code=site_code) - if response: - cache.set(cache_key, response, config.get('SAILTHRU_CACHE_TTL_SECONDS')) - - return response - - -def _get_course_content_from_ecommerce(course_id, site_code=None): - """ - Get course information using the Ecommerce course api. - - In case of error returns empty response. - Arguments: - course_id (str): course key of the course - site_code (str): site code - - Returns: - course information from Ecommerce - """ - api = get_ecommerce_client(site_code=site_code) - try: - api_response = api.courses(course_id).get() - except Exception: # pylint: disable=broad-except - logger.exception( - 'An error occurred while retrieving data for course run [%s] from the Catalog API.', - course_id, - exc_info=True - ) - return {} - - return { - 'title': api_response.get('name'), - 'verification_deadline': api_response.get('verification_deadline') - } - - -def _update_unenrolled_list(sailthru_client, email, course_url, unenroll): - """Maintain a list of courses the user has unenrolled from in the Sailthru user record - - Arguments: - sailthru_client (object): SailthruClient - email (str): user's email address - course_url (str): LMS url for course info page. - unenroll (boolean): True if unenrolling, False if enrolling - - Returns: - False if retryable error, else True - """ - try: - # get the user 'vars' values from sailthru - sailthru_response = sailthru_client.api_get("user", {"id": email, "fields": {"vars": 1}}) - if not sailthru_response.is_ok(): - error = sailthru_response.get_error() - logger.error("Error attempting to read user record from Sailthru: %s", error.get_message()) - return not can_retry_sailthru_request(error) - - response_json = sailthru_response.json - - unenroll_list = [] - if response_json and "vars" in response_json and response_json["vars"] \ - and "unenrolled" in response_json["vars"]: - unenroll_list = response_json["vars"]["unenrolled"] - - changed = False - # if unenrolling, add course to unenroll list - if unenroll: - if course_url not in unenroll_list: - unenroll_list.append(course_url) - changed = True - - # if enrolling, remove course from unenroll list - elif course_url in unenroll_list: - unenroll_list.remove(course_url) - changed = True - - if changed: - # write user record back - sailthru_response = sailthru_client.api_post( - 'user', {'id': email, 'key': 'email', 'vars': {'unenrolled': unenroll_list}}) - - if not sailthru_response.is_ok(): - error = sailthru_response.get_error() - logger.error("Error attempting to update user record in Sailthru: %s", error.get_message()) - return not can_retry_sailthru_request(error) - - return True - - except SailthruClientError as exc: - logger.exception("Exception attempting to update user record for %s in Sailthru - %s", email, text_type(exc)) - return False - - -def _send_offer_assignment_email_via_braze(self, user_email, offer_assignment_id, subject, email_body, sender_alias, - site_code=None, base_enterprise_url=''): - """ - Sends the offer assignment email via Braze. - - Args: - self: Ignore. - user_email (str): Recipient's email address. - offer_assignment_id (str): Key of the entry in the offer_assignment model. - subject (str): Email subject. - email_body (str): The body of the email. - site_code (str): Identifier of the site sending the email. - base_enterprise_url (str): Url for the enterprise learner portal. - sender_alias (str): Enterprise Customer sender alias used as From Name. - """ - config = get_braze_configuration(site_code) - try: - user_emails = [user_email] - braze_client = get_braze_client(site_code) - response = braze_client.send_message( - email_ids=user_emails, - subject=subject, - body=email_body, - sender_alias=sender_alias - ) - if response and response['success']: - dispatch_id = response['dispatch_id'] - if _update_assignment_email_status(offer_assignment_id, dispatch_id, 'success'): - logger.info('[Offer Assignment] Offer assignment notification sent with message --- ' - '{message}'.format(message=email_body)) - else: - logger.exception( - '[Offer Assignment] An error occurred while updating email status data for ' - 'offer {token_offer} and email {token_email} via the ecommerce API.'.format( - token_offer=offer_assignment_id, - token_email=user_email, - ) - ) - except (BrazeRateLimitError, BrazeInternalServerError): - raise self.retry(countdown=config.get('BRAZE_RETRY_SECONDS'), - max_retries=config.get('BRAZE_RETRY_ATTEMPTS')) - except BrazeError: - logger.exception( - ('[Offer Assignment] Error in offer assignment notification with message --- ' - '{message}'.format(message=email_body) - ) - ) - - -def _send_offer_assignment_email_via_sailthru(self, user_email, offer_assignment_id, subject, email_body, sender_alias, - site_code=None, base_enterprise_url=''): - """ - Sends the offer assignment email via Sailthru. - - Args: - self: Ignore. - user_email (str): Recipient's email address. - offer_assignment_id (str): Key of the entry in the offer_assignment model. - subject (str): Email subject. - email_body (str): The body of the email. - site_code (str): Identifier of the site sending the email. - base_enterprise_url (str): Url for the enterprise learner portal. - sender_alias (str): Enterprise Customer sender alias used as From Name. - """ - config = get_sailthru_configuration(site_code) - notification = Notification( - config=config, - emails=user_email, - email_vars={ - 'subject': subject, - 'email_body': email_body, - BASE_ENTERPRISE_URL_VAR_NAME: base_enterprise_url, - 'sender_alias': sender_alias, - }, - logger_prefix='Offer Assignment', - site_code=site_code, - template='enterprise_portal_email' - ) - response, is_eligible_for_retry = notification.send() - if is_eligible_for_retry: - schedule_retry(self, config) - - if response and response.is_ok(): - send_id = response.get_body().get('send_id') # pylint: disable=no-member - if _update_assignment_email_status(offer_assignment_id, send_id, 'success'): - logger.info('[Offer Assignment] Offer assignment notification sent with message --- ' - '{message}; base enterprise url --- {base_enterprise_url}'.format( - message=email_body, - base_enterprise_url=base_enterprise_url)) - else: - logger.exception( - '[Offer Assignment] An error occurred while updating email status data for ' - 'offer {token_offer} and email {token_email} via the ecommerce API.'.format( - token_offer=offer_assignment_id, - token_email=user_email, - ) - ) - - -@shared_task(bind=True, ignore_result=True) -def send_offer_assignment_email(self, user_email, offer_assignment_id, subject, email_body, sender_alias, - site_code=None, base_enterprise_url=''): - """ - Sends the offer assignment email. - - Args: - self: Ignore. - user_email (str): Recipient's email address. - offer_assignment_id (str): Key of the entry in the offer_assignment model. - subject (str): Email subject. - email_body (str): The body of the email. - site_code (str): Identifier of the site sending the email. - base_enterprise_url (str): Url for the enterprise learner portal. - sender_alias (str): Enterprise Customer sender alias used as From Name. - """ - config = get_braze_configuration(site_code) - braze_enable = config.get('BRAZE_ENABLE') - if braze_enable: - _send_offer_assignment_email_via_braze( - self, user_email, offer_assignment_id, subject, email_body, sender_alias, site_code, base_enterprise_url) - else: - _send_offer_assignment_email_via_sailthru( - self, user_email, - offer_assignment_id, - subject, - email_body, - sender_alias, - site_code=site_code, - base_enterprise_url=base_enterprise_url - ) - - -def _update_assignment_email_status(offer_assignment_id, send_id, status, site_code=None): - """ - Update the offer_assignment and offer_assignment_email model using the Ecommerce assignmentemail api. - Arguments: - offer_assignment_id (str): Key of the entry in the offer_assignment model. - send_id (str): Unique message id from Sailthru - status (str): status to be sent to the api - site_code (str): site code - Returns: - True or False based on model update status from Ecommerce api - """ - api = get_ecommerce_client(url_postfix='assignment-email/', site_code=site_code) - post_data = { - 'offer_assignment_id': offer_assignment_id, - 'send_id': send_id, - 'status': status, - } - try: - api_response = api.status().post(post_data) - except RequestException: - logger.exception( - '[Offer Assignment] An error occurred while updating offer assignment email status for ' - 'offer id {token_offer} and message id {token_send_id} via the Ecommerce API.'.format( - token_offer=offer_assignment_id, - token_send_id=send_id - ) - ) - return False - return bool(api_response.get('status') == 'updated') - - -def _send_offer_update_email_via_braze(self, user_email, subject, email_body, sender_alias, site_code=None): - """ - Sends the offer emails after assignment via braze, either for revoking or reminding. - - Args: - self: Ignore. - user_email (str): Recipient's email address. - subject (str): Email subject. - email_body (str): The body of the email. - site_code (str): Identifier of the site sending the email. - sender_alias (str): Enterprise Customer sender alias used as From Name. - """ - config = get_braze_configuration(site_code) - try: - user_emails = [user_email] - braze_client = get_braze_client(site_code) - braze_client.send_message( - email_ids=user_emails, - subject=subject, - body=email_body, - sender_alias=sender_alias - ) - except (BrazeRateLimitError, BrazeInternalServerError): - raise self.retry(countdown=config.get('BRAZE_RETRY_SECONDS'), - max_retries=config.get('BRAZE_RETRY_ATTEMPTS')) - except BrazeError: - logger.exception( - '[Offer Assignment] Error in offer update notification with message --- ' - '{message}'.format(message=email_body) - ) - - -def _send_offer_update_email_via_sailthru(self, user_email, subject, email_body, sender_alias, site_code=None, base_enterprise_url=''): - """ - Sends the offer emails after assignment via sailthru, either for revoking or reminding. - - Args: - self: Ignore. - user_email (str): Recipient's email address. - subject (str): Email subject. - email_body (str): The body of the email. - site_code (str): Identifier of the site sending the email. - sender_alias (str): Enterprise Customer sender alias used as From Name. - """ - config = get_sailthru_configuration(site_code) - notification = Notification( - config=config, - emails=user_email, - email_vars={ - 'subject': subject, - 'email_body': email_body, - 'sender_alias': sender_alias, - BASE_ENTERPRISE_URL_VAR_NAME: base_enterprise_url - }, - logger_prefix='Offer Assignment', - site_code=site_code, - template='enterprise_portal_email' - ) - _, is_eligible_for_retry = notification.send() - if is_eligible_for_retry: - schedule_retry(self, config) - - -@shared_task(bind=True, ignore_result=True) -def send_offer_update_email(self, user_email, subject, email_body, sender_alias, site_code=None, base_enterprise_url=''): - """ - Sends the offer emails after assignment, either for revoking or reminding. - - Args: - self: Ignore. - user_email (str): Recipient's email address. - subject (str): Email subject. - email_body (str): The body of the email. - site_code (str): Identifier of the site sending the email. - sender_alias (str): Enterprise Customer sender alias used as From Name. - base_enterprise_url (str): Enterprise learner portal url. - """ - config = get_braze_configuration(site_code) - braze_enable = config.get('BRAZE_ENABLE') - if braze_enable: - _send_offer_update_email_via_braze(self, user_email, subject, email_body, sender_alias, site_code) - else: - _send_offer_update_email_via_sailthru(self, - user_email, - subject, - email_body, - sender_alias, - site_code=site_code, - base_enterprise_url=base_enterprise_url - ) - - -def _send_offer_usage_email_via_braze(self, emails, subject, email_body, site_code=None): - """ - Sends the offer usage email via braze. - - Args: - self: Ignore. - emails (str): comma separated emails. - subject (str): Email subject. - email_body (str): The body of the email. - site_code (str): Identifier of the site sending the email. - """ - config = get_braze_configuration(site_code) - try: - user_emails = list(emails.strip().split(",")) - braze_client = get_braze_client(site_code) - braze_client.send_message( - email_ids=user_emails, - subject=subject, - body=email_body - ) - except (BrazeRateLimitError, BrazeInternalServerError): - raise self.retry(countdown=config.get('BRAZE_RETRY_SECONDS'), - max_retries=config.get('BRAZE_RETRY_ATTEMPTS')) - except BrazeError: - logger.exception( - '[Offer Usage] Error in offer usage notification with message --- ' - '{message}'.format(message=email_body) - ) - - -def _send_offer_usage_email_via_sailthru(self, emails, subject, email_body, site_code=None, base_enterprise_url=''): - """ - Sends the offer usage email via sailthru. - - Args: - self: Ignore. - emails (str): comma separated emails. - subject (str): Email subject. - email_body (str): The body of the email. - site_code (str): Identifier of the site sending the email. - base_enterprise_url (str): Url for the enterprise's learner portal - """ - config = get_sailthru_configuration(site_code) - notification = Notification( - config=config, - emails=emails, - email_vars={ - 'subject': subject, - 'email_body': email_body, - BASE_ENTERPRISE_URL_VAR_NAME: base_enterprise_url - }, - logger_prefix='Offer Usage', - site_code=site_code, - template='assignment_email', - ) - _, is_eligible_for_retry = notification.send(is_multi_send=True) - if is_eligible_for_retry: - schedule_retry(self, config) - - -@shared_task(bind=True, ignore_result=True) -def send_offer_usage_email(self, emails, subject, email_body, site_code=None, base_enterprise_url=''): - """ - Sends the offer usage email. - - Args: - self: Ignore. - emails (str): comma separated emails. - subject (str): Email subject. - email_body (str): The body of the email. - site_code (str): Identifier of the site sending the email. - base_enterprise_url (str): Url of the enterprise's learner portal - """ - config = get_braze_configuration(site_code) - braze_enable = config.get('BRAZE_ENABLE') - if braze_enable: - _send_offer_usage_email_via_braze(self, emails, subject, email_body, site_code) - else: - _send_offer_usage_email_via_sailthru( - self, - emails, - subject, - email_body, - site_code=site_code, - base_enterprise_url=base_enterprise_url - ) - - -def _send_code_assignment_nudge_email_via_braze(self, email, subject, email_body, sender_alias, site_code=None): - """ - Sends the code assignment nudge email via braze. - - Args: - self: Ignore. - email (str): Recipient's email address. - subject (str): Email subject. - email_body (str): The body of the email. - sender_alias (str): Enterprise Customer sender alias used as From Name. - site_code (str): Identifier of the site sending the email. - """ - config = get_braze_configuration(site_code) - try: - user_emails = [email] - braze_client = get_braze_client(site_code) - braze_client.send_message( - email_ids=user_emails, - subject=subject, - body=email_body, - sender_alias=sender_alias - ) - except (BrazeRateLimitError, BrazeInternalServerError): - raise self.retry(countdown=config.get('BRAZE_RETRY_SECONDS'), - max_retries=config.get('BRAZE_RETRY_ATTEMPTS')) - except BrazeError: - logger.exception( - '[Code Assignment Nudge Email] Error in offer nudge notification with message --- ' - '{message}'.format(message=email_body) - ) - - -def _send_code_assignment_nudge_email_via_sailthru(self, email, subject, email_body, sender_alias, site_code=None, base_enterprise_url=''): - """ - Sends the code assignment nudge email via sailthru. - - Args: - self: Ignore. - email (str): Recipient's email address. - subject (str): Email subject. - email_body (str): The body of the email. - sender_alias (str): Enterprise Customer sender alias used as From Name. - site_code (str): Identifier of the site sending the email. - base_enterprise_url (str): Enterprise learner portal url - """ - config = get_sailthru_configuration(site_code) - notification = Notification( - config=config, - emails=email, - email_vars={ - 'subject': subject, - 'email_body': email_body, - 'sender_alias': sender_alias, - BASE_ENTERPRISE_URL_VAR_NAME: base_enterprise_url, - }, - logger_prefix='Code Assignment Nudge Email', - site_code=site_code, - template='enterprise_portal_email' - ) - _, is_eligible_for_retry = notification.send() - if is_eligible_for_retry: - schedule_retry(self, config) - - -@shared_task(bind=True, ignore_result=True) -def send_code_assignment_nudge_email(self, email, subject, email_body, sender_alias, site_code=None, base_enterprise_url=''): - """ - Sends the code assignment nudge email. - - Args: - self: Ignore. - email (str): Recipient's email address. - subject (str): Email subject. - email_body (str): The body of the email. - sender_alias (str): Enterprise Customer sender alias used as From Name. - site_code (str): Identifier of the site sending the email. - base_enterprise_url (str): Enterprise learner portal url. - """ - config = get_braze_configuration(site_code) - braze_enable = config.get('BRAZE_ENABLE') - if braze_enable: - _send_code_assignment_nudge_email_via_braze(self, email, subject, email_body, sender_alias, site_code) - else: - _send_code_assignment_nudge_email_via_sailthru(self, - email, - subject, - email_body, - sender_alias, - site_code=site_code, - base_enterprise_url=base_enterprise_url - ) diff --git a/ecommerce_worker/sailthru/v1/tests/__init__.py b/ecommerce_worker/sailthru/v1/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ecommerce_worker/sailthru/v1/tests/test_tasks.py b/ecommerce_worker/sailthru/v1/tests/test_tasks.py deleted file mode 100644 index 4d9ce65d..00000000 --- a/ecommerce_worker/sailthru/v1/tests/test_tasks.py +++ /dev/null @@ -1,946 +0,0 @@ -"""Tests of Sailthru worker code.""" -from __future__ import absolute_import, unicode_literals -import logging -import json -from decimal import Decimal -from unittest import TestCase -import ddt - -import responses -from celery.exceptions import Retry -from mock import patch, Mock -from sailthru import SailthruClient -from sailthru.sailthru_error import SailthruClientError -from testfixtures import LogCapture -from requests.exceptions import RequestException -from six import text_type -from ecommerce_worker.braze.v1.client import BrazeClient -from ecommerce_worker.braze.v1.exceptions import ( - BrazeError, - BrazeClientError, - BrazeInternalServerError, - BrazeRateLimitError -) -from ecommerce_worker.sailthru.v1.exceptions import SailthruError -from ecommerce_worker.sailthru.v1.tasks import ( - _update_assignment_email_status, - _update_unenrolled_list, - _get_course_content, - _get_course_content_from_ecommerce, - send_code_assignment_nudge_email, - send_offer_assignment_email, - send_offer_update_email, - send_offer_usage_email, -) -from ecommerce_worker.sailthru.v1.constants import BASE_ENTERPRISE_URL_VAR_NAME -from ecommerce_worker.utils import get_configuration - -TEST_EMAIL = "test@edx.org" - - -class MockSailthruResponse: - """ - Mock object for SailthruResponse - """ - - def __init__(self, json_response, error=None, code=1): - self.json = json_response - self.error = error - self.code = code - - def is_ok(self): - """ - Return true of no error - """ - return self.error is None - - def get_error(self): - """ - Get error description - """ - return MockSailthruError(self.error, self.code) - - -class MockSailthruError: - """ - Mock object for Sailthru Error - """ - - def __init__(self, error, code=1): - self.error = error - self.code = code - - def get_message(self): - """ - Get error description - """ - return self.error - - def get_error_code(self): - """ - Get error code - """ - return self.code - - -class SailthruTests(TestCase): - """ - Tests for the Sailthru tasks class. - """ - - def setUp(self): - super(SailthruTests, self).setUp() - self.course_id = 'edX/toy/2012_Fall' - self.course_url = 'http://lms.testserver.fake/courses/edX/toy/2012_Fall/info' - self.course_id2 = 'edX/toy/2016_Fall' - self.course_url2 = 'http://lms.testserver.fake/courses/edX/toy/2016_Fall/info' - - def mock_ecommerce_api(self, body, course_id, status=200): - """ Mock GET requests to the ecommerce course API endpoint. """ - responses.reset() - responses.add( - responses.GET, '{}/courses/{}/'.format( - get_configuration('ECOMMERCE_API_ROOT').strip('/'), text_type(course_id) - ), - status=status, - body=json.dumps(body), content_type='application/json', - ) - - def ecom_course_data(self, course_id): - """ Returns dummy course data. """ - return { - "id": course_id, - "url": "https://test-ecommerce.edx.org/api/v2/courses/{}/".format(course_id), - "name": "test course", - "verification_deadline": "2016-12-01T23:59:00Z", - "type": "verified", - "products_url": "https://test-ecommerce.edx.org/api/v2/courses/{}/products/".format(course_id), - "last_edited": "2016-12-28T15:20:58Z" - } - - - @responses.activate - @patch('ecommerce_worker.sailthru.v1.utils.SailthruClient') - def test_get_course_content(self, mock_sailthru_client): - """ - test routine which fetches data from Sailthru content api - """ - config = {'SAILTHRU_CACHE_TTL_SECONDS': 100} - mock_sailthru_client.api_get.return_value = MockSailthruResponse({"title": "The title"}) - response_json = _get_course_content(self.course_id, 'course:123', mock_sailthru_client, None, config) - self.assertEqual(response_json, {"title": "The title"}) - mock_sailthru_client.api_get.assert_called_with('content', {'id': 'course:123'}) - - # test second call uses cache - mock_sailthru_client.reset_mock() - response_json = _get_course_content(self.course_id, 'course:123', mock_sailthru_client, None, config) - self.assertEqual(response_json, {"title": "The title"}) - mock_sailthru_client.api_get.assert_not_called() - - # test error from Sailthru - mock_sailthru_client.api_get.return_value = MockSailthruResponse({}, error='Got an error') - data = self.ecom_course_data(self.course_id) - expected_response = { - 'title': 'test course', - 'verification_deadline': '2016-12-01T23:59:00Z' - } - self.mock_ecommerce_api(data, self.course_id) - self.assertEqual( - _get_course_content(self.course_id, 'course:124', mock_sailthru_client, None, config), expected_response - ) - - # test Sailthru exception - data = self.ecom_course_data(self.course_id) - expected_response = { - 'title': 'test course', - 'verification_deadline': '2016-12-01T23:59:00Z' - } - mock_sailthru_client.api_get.side_effect = SailthruClientError - self.mock_ecommerce_api(data, self.course_id) - self.assertEqual( - _get_course_content(self.course_id, 'course:125', mock_sailthru_client, None, config), expected_response - ) - - # test Sailthru and Ecommerce exception - mock_sailthru_client.api_get.side_effect = SailthruClientError - self.mock_ecommerce_api({}, self.course_id2, status=500) - self.assertEqual( - _get_course_content(self.course_id2, 'course:126', mock_sailthru_client, None, config), {} - ) - - @responses.activate - def test_get_course_content_from_ecommerce(self): - """ - Test routine that fetches data from ecommerce. - """ - data = self.ecom_course_data(self.course_id) - expected_response = { - 'title': 'test course', - 'verification_deadline': '2016-12-01T23:59:00Z' - } - self.mock_ecommerce_api(data, self.course_id) - - response_json = _get_course_content_from_ecommerce(self.course_id, None) - self.assertEqual(response_json, expected_response) - - # test error getting data - self.mock_ecommerce_api({}, self.course_id2, status=500) - response_json = _get_course_content_from_ecommerce(self.course_id2, None) - self.assertEqual(response_json, {}) - - @patch('ecommerce_worker.sailthru.v1.utils.SailthruClient') - def test_update_unenrolled_list_new(self, mock_sailthru_client): - """ - test routine which updates the unenrolled list in Sailthru - """ - - # test a new unenroll - mock_sailthru_client.api_get.return_value = MockSailthruResponse({'vars': {'unenrolled': ['course_u1']}}) - self.assertTrue(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, - self.course_url, True)) - mock_sailthru_client.api_get.assert_called_with("user", {"id": TEST_EMAIL, "fields": {"vars": 1}}) - mock_sailthru_client.api_post.assert_called_with( - 'user', - { - 'vars': {'unenrolled': ['course_u1', self.course_url]}, - 'id': TEST_EMAIL, 'key': 'email' - }) - - @patch('ecommerce_worker.sailthru.v1.utils.SailthruClient') - def test_update_unenrolled_list_old(self, mock_sailthru_client): - # test an existing unenroll - mock_sailthru_client.reset_mock() - mock_sailthru_client.api_get.return_value = MockSailthruResponse({'vars': {'unenrolled': [self.course_url]}}) - self.assertTrue(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, - self.course_url, True)) - mock_sailthru_client.api_get.assert_called_with("user", {"id": TEST_EMAIL, "fields": {"vars": 1}}) - mock_sailthru_client.api_post.assert_not_called() - - @patch('ecommerce_worker.sailthru.v1.utils.SailthruClient') - def test_update_unenrolled_list_reenroll(self, mock_sailthru_client): - # test an enroll of a previously unenrolled course - mock_sailthru_client.reset_mock() - mock_sailthru_client.api_get.return_value = MockSailthruResponse({'vars': {'unenrolled': [self.course_url]}}) - self.assertTrue(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, - self.course_url, False)) - mock_sailthru_client.api_post.assert_called_with( - 'user', - { - 'vars': {'unenrolled': []}, - 'id': TEST_EMAIL, 'key': 'email' - }) - - @patch('ecommerce_worker.sailthru.v1.utils.SailthruClient') - def test_update_unenrolled_list_errors(self, mock_sailthru_client): - # test get error from Sailthru - mock_sailthru_client.reset_mock() - # simulate retryable error - mock_sailthru_client.api_get.return_value = MockSailthruResponse({}, error='Got an error', code=43) - self.assertFalse(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, - self.course_url, False)) - - # test get error from Sailthru - mock_sailthru_client.reset_mock() - # simulate unretryable error - mock_sailthru_client.api_get.return_value = MockSailthruResponse({}, error='Got an error', code=1) - self.assertTrue(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, - self.course_url, False)) - - # test post error from Sailthru - mock_sailthru_client.reset_mock() - mock_sailthru_client.api_post.return_value = MockSailthruResponse({}, error='Got an error', code=9) - mock_sailthru_client.api_get.return_value = MockSailthruResponse({'vars': {'unenrolled': [self.course_url]}}) - self.assertFalse(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, - self.course_url, False)) - - # test exception - mock_sailthru_client.api_get.side_effect = SailthruClientError - self.assertFalse(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, - self.course_url, False)) - - -class BaseSendEmailTests(TestCase): - """ - Base class for testing the sending notification through sailthru client. - """ - - def mock_api_response(self, status, body): - """ Mock the Sailthru send API. """ - responses.add( - responses.POST, - 'https://api.sailthru.com/send', - status=status, - body=json.dumps(body), - content_type='application/json' - ) - - -@ddt.ddt -class SendOfferEmailsTests(BaseSendEmailTests): - """ Validates the send_offer_assignment_email task. """ - LOG_NAME = 'ecommerce_worker.sailthru.v1.notification' - LOG_TASK_NAME = 'ecommerce_worker.sailthru.v1.tasks' - USER_EMAIL = 'user@unknown.com' - EMAILS = 'user@unknown.com, user1@example.com' - OFFER_ASSIGNMENT_ID = '555' - SEND_ID = '1234ABC' - SUBJECT = 'New edX course assignment' - EMAIL_BODY = 'Template message with johndoe@unknown.com GIL7RUEOU7VHBH7Q ' \ - 'http://tempurl.url/enroll 3 2012-04-23' - BASE_ENTERPRISE_URL = 'https://bears.party' - SENDER_ALIAS = 'edx Support Team' - - ASSIGNMENT_TASK_KWARGS = { - 'user_email': USER_EMAIL, - 'offer_assignment_id': OFFER_ASSIGNMENT_ID, - 'subject': SUBJECT, - 'email_body': EMAIL_BODY, - BASE_ENTERPRISE_URL_VAR_NAME: BASE_ENTERPRISE_URL, - 'sender_alias': SENDER_ALIAS, - } - - UPDATE_TASK_KWARGS = { - 'user_email': USER_EMAIL, - 'subject': SUBJECT, - 'email_body': EMAIL_BODY, - BASE_ENTERPRISE_URL_VAR_NAME: BASE_ENTERPRISE_URL, - 'sender_alias': SENDER_ALIAS, - } - - USAGE_TASK_KWARGS = { - 'emails': EMAILS, - 'subject': SUBJECT, - BASE_ENTERPRISE_URL_VAR_NAME: BASE_ENTERPRISE_URL, - 'email_body': EMAIL_BODY, - } - - NUDGE_TASK_KWARGS = { - 'email': USER_EMAIL, - 'subject': SUBJECT, - 'email_body': EMAIL_BODY, - BASE_ENTERPRISE_URL_VAR_NAME: BASE_ENTERPRISE_URL, - 'sender_alias': SENDER_ALIAS, - } - - def execute_task(self): - """ Execute the send_offer_assignment_email task. """ - send_offer_assignment_email(**self.ASSIGNMENT_TASK_KWARGS) - - def mock_ecommerce_assignmentemail_api(self, body, status=200): - """ Mock POST requests to the ecommerce assignmentemail API endpoint. """ - responses.reset() - responses.add( - responses.POST, '{}/assignment-email/status/'.format( - get_configuration('ECOMMERCE_API_ROOT').strip('/') - ), - status=status, - body=json.dumps(body), content_type='application/json', - ) - - @patch('ecommerce_worker.sailthru.v1.notification.get_sailthru_client', Mock(side_effect=SailthruError)) - @ddt.data( - (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS, "Offer Assignment"), - (send_offer_update_email, UPDATE_TASK_KWARGS, "Offer Assignment"), - (send_offer_usage_email, USAGE_TASK_KWARGS, "Offer Usage"), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS, "Code Assignment Nudge Email"), - ) - @ddt.unpack - def test_client_instantiation_error(self, task, task_kwargs, logger_prefix): - """ Verify no message is sent if an error occurs while instantiating the Sailthru API client. """ - with LogCapture(level=logging.INFO) as log: - task(**task_kwargs) - log.check( - ( - self.LOG_NAME, - 'ERROR', - '[{logger_prefix}] A client error occurred while attempting to send a notification. ' - 'Message: {message}'.format( - logger_prefix=logger_prefix, - message=self.EMAIL_BODY - ) - ), - ) - - @patch('ecommerce_worker.sailthru.v1.notification.log.exception') - @ddt.data( - (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS, "send", "Offer Assignment"), - (send_offer_update_email, UPDATE_TASK_KWARGS, "send", "Offer Assignment"), - (send_offer_usage_email, USAGE_TASK_KWARGS, "multi_send", "Offer Usage"), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS, "send", "Code Assignment Nudge Email"), - ) - @ddt.unpack - def test_api_client_error(self, task, task_kwargs, mock_send, logger_prefix, mock_log): - """ Verify API client errors are logged. """ - with patch.object(SailthruClient, mock_send, side_effect=SailthruClientError): - task(**task_kwargs) - mock_log.assert_called_once_with( - '[{logger_prefix}] A client error occurred while attempting to send a notification.' - ' Message: {message}'.format( - logger_prefix=logger_prefix, - message=self.EMAIL_BODY - ) - ) - - @responses.activate - @ddt.data( - (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS, "Offer Assignment"), - (send_offer_update_email, UPDATE_TASK_KWARGS, "Offer Assignment"), - (send_offer_usage_email, USAGE_TASK_KWARGS, "Offer Usage"), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS, "Code Assignment Nudge Email"), - ) - @ddt.unpack - def test_api_error_with_retry(self, task, task_kwargs, logger_prefix): - """ Verify the task is rescheduled if an API error occurs, and the request can be retried. """ - error_code = 43 - error_msg = 'This is a fake error.' - body = { - 'error': error_code, - 'errormsg': error_msg - } - self.mock_api_response(429, body) - with LogCapture(level=logging.INFO) as log: - with self.assertRaises(Retry): - task(**task_kwargs) - log.check( - ( - self.LOG_NAME, - 'ERROR', - '[{logger_prefix}] An error occurred while attempting to send a notification. Message: {message},' - ' Error code: {token_error_code}, Error Message: {token_error_message}.'.format( - logger_prefix=logger_prefix, - message=self.EMAIL_BODY, - token_error_code=error_code, - token_error_message=error_msg - ) - ), - ( - self.LOG_NAME, - 'INFO', - '[{logger_prefix}] An attempt will be made to resend the notification. Message: {message}'.format( - logger_prefix=logger_prefix, - message=self.EMAIL_BODY - ) - ), - ) - - @responses.activate - @ddt.data( - (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS, "Offer Assignment"), - (send_offer_update_email, UPDATE_TASK_KWARGS, "Offer Assignment"), - (send_offer_usage_email, USAGE_TASK_KWARGS, "Offer Usage"), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS, "Code Assignment Nudge Email"), - ) - @ddt.unpack - def test_api_error_without_retry(self, task, task_kwargs, logger_prefix): - """ Verify error details are logged if an API error occurs, and the request can NOT be retried. """ - error_code = 1 - error_msg = 'This is a fake error.' - body = { - 'error': error_code, - 'errormsg': error_msg - } - self.mock_api_response(500, body) - with LogCapture(level=logging.INFO) as log: - task(**task_kwargs) - log.check( - ( - self.LOG_NAME, - 'ERROR', - '[{logger_prefix}] An error occurred while attempting to send a notification. Message: {message},' - ' Error code: {token_error_code}, Error Message: {token_error_message}.'.format( - logger_prefix=logger_prefix, - message=self.EMAIL_BODY, - token_error_code=error_code, - token_error_message=error_msg - ) - ), - - ( - self.LOG_NAME, - 'WARNING', - '[{logger_prefix}] No further attempts will be made to send the notification.' - ' Failed Message: {message}'.format( - logger_prefix=logger_prefix, - message=self.EMAIL_BODY - ) - ), - ) - - @responses.activate - @ddt.data( - (send_offer_update_email, UPDATE_TASK_KWARGS, "Offer Assignment"), - (send_offer_usage_email, USAGE_TASK_KWARGS, "Offer Usage"), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS, "Code Assignment Nudge Email"), - ) - @ddt.unpack - def test_api(self, task, task_kwargs, logger_prefix): - """ - Test the happy path. - """ - body = { - 'dummy-key': 'dummy-value' - } - self.mock_api_response(200, body) - with LogCapture(level=logging.INFO) as log: - task(**task_kwargs) - log.check( - ( - self.LOG_NAME, - 'INFO', - '[{logger_prefix}] A notification has been sent successfully. Message: {message}'.format( - logger_prefix=logger_prefix, - message=self.EMAIL_BODY - ) - ) - ) - - @responses.activate - @patch('ecommerce_worker.sailthru.v1.tasks._update_assignment_email_status') - def test_message_sent(self, mock_update_assignment): - """ Verify a message is logged after a successful API call to send the message. """ - self.mock_api_response(200, {'send_id': '1234ABC'}) - with LogCapture(level=logging.INFO) as log: - self.execute_task() - log.check( - ( - self.LOG_NAME, - 'INFO', - '[Offer Assignment] A notification has been sent successfully. Message: {message}'.format( - message=self.EMAIL_BODY - ) - ), - ( - self.LOG_TASK_NAME, - 'INFO', - '[Offer Assignment] Offer assignment notification sent with message --- ' - '{message}; base enterprise url --- {base_enterprise_url}'.format( - message=self.EMAIL_BODY, - base_enterprise_url=self.BASE_ENTERPRISE_URL - ) - ), - ) - self.assertEqual(mock_update_assignment.call_count, 1) - - @responses.activate - @patch('ecommerce_worker.utils.get_ecommerce_client') - def test_update_assignment_exception(self, mock_get_ecommerce_client): - """ Verify a message is logged after an unsuccessful API call to update the status. """ - self.mock_api_response(200, {'send_id': '1234ABC'}) - mock_get_ecommerce_client.side_effect = RequestException - with LogCapture(level=logging.INFO) as log: - self.execute_task() - log.check( - ( - self.LOG_NAME, - 'INFO', - '[Offer Assignment] A notification has been sent successfully. Message: {message}'.format( - message=self.EMAIL_BODY - ) - ), - ( - self.LOG_TASK_NAME, - 'ERROR', - '[Offer Assignment] An error occurred while updating offer assignment email status for offer id ' - '{token_offer} and message id {token_send_id} via the Ecommerce API.'.format( - token_offer=self.OFFER_ASSIGNMENT_ID, - token_send_id=self.SEND_ID - ) - ), - ( - self.LOG_TASK_NAME, - 'ERROR', - '[Offer Assignment] An error occurred while updating email status data for offer {token_offer} and ' - 'email {token_email} via the ecommerce API.'.format( - token_offer=self.OFFER_ASSIGNMENT_ID, - token_email=self.USER_EMAIL - ) - ) - ) - - @responses.activate - @ddt.data( - ( - # Success case - { - 'offer_assignment_id': '555', - 'send_id': '1234ABC', - 'status': 'updated', - 'error': '' - }, - True, - ), - ( - # Exception case - { - 'offer_assignment_id': '555', - 'send_id': '1234ABC', - 'status': 'failed', - 'error': '' - }, - False, - ), - ) - @ddt.unpack - def test_update_assignment_email_status(self, data, return_value): - """ - Test routine that updates email send status in ecommerce. - """ - self.mock_ecommerce_assignmentemail_api(data) - self.assertEqual( - _update_assignment_email_status('555', '1234ABC', 'success'), - return_value - ) - - -@ddt.ddt -class SendOfferEmailsTestsWithBraze(TestCase): - """ Validates the send_offer_assignment_email task with Braze api. """ - LOG_NAME = 'ecommerce_worker.sailthru.v1.tasks' - LOG_TASK_NAME = 'ecommerce_worker.sailthru.v1.tasks' - USER_EMAIL = 'user@unknown.com' - EMAILS = 'user@unknown.com, user1@example.com' - OFFER_ASSIGNMENT_ID = '555' - SEND_ID = '66cdc28f8f082bc3074c0c79f' - SUBJECT = 'New edX course assignment' - EMAIL_BODY = 'Template message with johndoe@unknown.com GIL7RUEOU7VHBH7Q ' \ - 'http://tempurl.url/enroll 3 2012-04-23' - BASE_ENTERPRISE_URL = 'https://bears.party' - SENDER_ALIAS = 'edx Support Team' - SITE_CODE = 'test' - SITE_OVERRIDES_MODULE = 'ecommerce_worker.configuration.test.SITE_OVERRIDES' - BRAZE_OVERRIDES = { - SITE_CODE: { - 'BRAZE': { - 'BRAZE_ENABLE': True, - 'BRAZE_REST_API_KEY': 'rest_api_key', - 'BRAZE_WEBAPP_API_KEY': 'webapp_api_key', - 'REST_API_URL': 'https://rest.iad-06.braze.com', - 'MESSAGES_SEND_ENDPOINT': '/messages/send', - 'EMAIL_BOUNCE_ENDPOINT': '/email/hard_bounces', - 'NEW_ALIAS_ENDPOINT': '/users/alias/new', - 'USERS_TRACK_ENDPOINT': '/users/track', - 'EXPORT_ID_ENDPOINT': '/users/export/ids', - 'CAMPAIGN_SEND_ENDPOINT': '/campaigns/trigger/send', - 'ENTERPRISE_CAMPAIGN_ID': '', - 'FROM_EMAIL': '', - 'BRAZE_RETRY_SECONDS': 3600, - 'BRAZE_RETRY_ATTEMPTS': 6, - } - } - } - - ASSIGNMENT_TASK_KWARGS = { - 'user_email': USER_EMAIL, - 'offer_assignment_id': OFFER_ASSIGNMENT_ID, - 'subject': SUBJECT, - 'email_body': EMAIL_BODY, - BASE_ENTERPRISE_URL_VAR_NAME: BASE_ENTERPRISE_URL, - 'sender_alias': SENDER_ALIAS, - 'site_code': 'test' - } - - UPDATE_TASK_KWARGS = { - 'user_email': USER_EMAIL, - 'subject': SUBJECT, - 'email_body': EMAIL_BODY, - 'sender_alias': SENDER_ALIAS, - 'site_code': 'test' - } - - USAGE_TASK_KWARGS = { - 'emails': EMAILS, - 'subject': SUBJECT, - 'email_body': EMAIL_BODY, - 'site_code': 'test' - } - - NUDGE_TASK_KWARGS = { - 'email': USER_EMAIL, - 'subject': SUBJECT, - 'email_body': EMAIL_BODY, - 'sender_alias': SENDER_ALIAS, - 'site_code': 'test' - } - - def execute_task(self): - """ Execute the send_offer_assignment_email task. """ - BRAZE = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.sailthru.v1.tasks.get_braze_configuration', Mock(return_value=BRAZE)): - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=BRAZE)): - send_offer_assignment_email(**self.ASSIGNMENT_TASK_KWARGS) - - def mock_ecommerce_assignmentemail_api(self, body, status=200): - """ Mock POST requests to the ecommerce assignmentemail API endpoint. """ - responses.reset() - responses.add( - responses.POST, '{}/assignment-email/status/'.format( - get_configuration('ECOMMERCE_API_ROOT').strip('/') - ), - status=status, - body=json.dumps(body), content_type='application/json', - ) - - def mock_braze_user_endpoints(self): - """ Mock POST requests to the user alias and track endpoints. """ - host = 'https://rest.iad-06.braze.com/users/track' - responses.add( - responses.POST, - host, - json={'message': 'success'}, - status=201 - ) - host = 'https://rest.iad-06.braze.com/users/alias/new' - responses.add( - responses.POST, - host, - json={'message': 'success'}, - status=201 - ) - host = 'https://rest.iad-06.braze.com/users/export/ids' - responses.add( - responses.POST, - host, - json={"users": [], "message": "success"}, - status=201 - ) - - @patch('ecommerce_worker.sailthru.v1.tasks.get_braze_client', Mock(side_effect=BrazeError)) - @ddt.data( - (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS, "Offer Assignment", 'assignment'), - (send_offer_update_email, UPDATE_TASK_KWARGS, "Offer Assignment", 'update'), - (send_offer_usage_email, USAGE_TASK_KWARGS, "Offer Usage", 'usage'), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS, "Code Assignment Nudge Email", 'nudge'), - ) - @ddt.unpack - def test_client_instantiation_error(self, task, task_kwargs, logger_prefix, log_message): - """ Verify no message is sent if an error occurs while instantiating the Braze API client. """ - BRAZE = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.sailthru.v1.tasks.get_braze_configuration', Mock(return_value=BRAZE)): - with LogCapture(level=logging.INFO) as log: - task(**task_kwargs) - log.check( - ( - self.LOG_NAME, - 'ERROR', - '[{logger_prefix}] Error in offer {log_message} notification with message --- ' - '{message}'.format( - logger_prefix=logger_prefix, - log_message=log_message, - message=self.EMAIL_BODY - ) - ), - ) - - @patch('ecommerce_worker.sailthru.v1.tasks.logger.exception') - @ddt.data( - (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS, "Offer Assignment", 'assignment'), - (send_offer_update_email, UPDATE_TASK_KWARGS, "Offer Assignment", 'update'), - (send_offer_usage_email, USAGE_TASK_KWARGS, "Offer Usage", 'usage'), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS, "Code Assignment Nudge Email", 'nudge'), - ) - @ddt.unpack - def test_api_client_error(self, task, task_kwargs, logger_prefix, log_message, mock_log): - """ Verify API client errors are logged. """ - BRAZE = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.sailthru.v1.tasks.get_braze_configuration', Mock(return_value=BRAZE)): - task(**task_kwargs) - mock_log.assert_called_once_with( - '[{logger_prefix}] Error in offer {log_message} notification with message --- ' - '{message}'.format( - logger_prefix=logger_prefix, - log_message=log_message, - message=self.EMAIL_BODY - ) - ) - - @responses.activate - @ddt.data( - (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS), - (send_offer_update_email, UPDATE_TASK_KWARGS), - (send_offer_usage_email, USAGE_TASK_KWARGS), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS), - ) - @ddt.unpack - def test_api_429_error_with_retry(self, task, task_kwargs): - """ Verify the task is rescheduled if an API error occurs, and the request can be retried. """ - self.mock_braze_user_endpoints() - failure_response = { - 'message': 'Not a Success', - 'status_code': 429 - } - host = 'https://rest.iad-06.braze.com/messages/send' - responses.add( - responses.POST, - host, - json=failure_response, - status=429 - ) - BRAZE = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with LogCapture(level=logging.INFO) as log: - with patch('ecommerce_worker.sailthru.v1.tasks.get_braze_configuration', Mock(return_value=BRAZE)): - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=BRAZE)): - with self.assertRaises((BrazeRateLimitError, Retry)): - task(**task_kwargs) - - @responses.activate - @ddt.data( - (send_offer_assignment_email, ASSIGNMENT_TASK_KWARGS), - (send_offer_update_email, UPDATE_TASK_KWARGS), - (send_offer_usage_email, USAGE_TASK_KWARGS), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS), - ) - @ddt.unpack - def test_api_500_error_with_retry(self, task, task_kwargs): - """ Verify 500 error triggers a request retry. """ - self.mock_braze_user_endpoints() - failure_response = { - 'message': 'Not a Success', - 'status_code': 500 - } - host = 'https://rest.iad-06.braze.com/messages/send' - responses.add( - responses.POST, - host, - json=failure_response, - status=500 - ) - BRAZE = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.sailthru.v1.tasks.get_braze_configuration', Mock(return_value=BRAZE)): - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=BRAZE)): - with LogCapture(level=logging.INFO) as log: - with self.assertRaises((BrazeInternalServerError, Retry)): - task(**task_kwargs) - - @responses.activate - @ddt.data( - (send_offer_update_email, UPDATE_TASK_KWARGS), - (send_offer_usage_email, USAGE_TASK_KWARGS), - (send_code_assignment_nudge_email, NUDGE_TASK_KWARGS), - ) - @ddt.unpack - def test_api(self, task, task_kwargs): - """ - Test the happy path. - """ - self.mock_braze_user_endpoints() - success_response = { - "dispatch_id": "66cdc28f8f082bc3074c0c79f", - "errors": [], - "message": "success", - "status_code": 201 - } - host = 'https://rest.iad-06.braze.com/messages/send' - responses.add( - responses.POST, - host, - json=success_response, - status=201) - BRAZE = self.BRAZE_OVERRIDES[self.SITE_CODE]['BRAZE'] - with patch('ecommerce_worker.sailthru.v1.tasks.get_braze_configuration', Mock(return_value=BRAZE)): - with patch('ecommerce_worker.braze.v1.client.get_braze_configuration', Mock(return_value=BRAZE)): - task(**task_kwargs) - self.assertTrue('success' in responses.calls[0].response.text) - - @responses.activate - @patch('ecommerce_worker.sailthru.v1.tasks._update_assignment_email_status') - def test_message_sent(self, mock_update_assignment): - """ Verify a message is logged after a successful API call to send the message. """ - self.mock_braze_user_endpoints() - success_response = { - 'dispatch_id': '66cdc28f8f082bc3074c0c79f', - 'errors': [], - 'message': 'success', - 'status_code': 201 - } - host = 'https://rest.iad-06.braze.com/messages/send' - responses.add( - responses.POST, - host, - json=success_response, - status=201) - with LogCapture(level=logging.INFO) as log: - self.execute_task() - log.check_present( - ( - self.LOG_TASK_NAME, - 'INFO', - '[Offer Assignment] Offer assignment notification sent with message --- ' - '{message}'.format(message=self.EMAIL_BODY) - ), - ) - self.assertEqual(mock_update_assignment.call_count, 1) - - @responses.activate - @patch('ecommerce_worker.utils.get_ecommerce_client') - def test_update_assignment_exception(self, mock_get_ecommerce_client): - """ Verify a message is logged after an unsuccessful API call to update the status. """ - self.mock_braze_user_endpoints() - success_response = { - 'dispatch_id': '66cdc28f8f082bc3074c0c79f', - 'errors': [], - 'message': 'success', - 'status_code': 201 - } - host = 'https://rest.iad-06.braze.com/messages/send' - responses.add( - responses.POST, - host, - json=success_response, - status=201) - mock_get_ecommerce_client.side_effect = RequestException - with LogCapture(level=logging.INFO) as log: - self.execute_task() - log.check_present( - ( - self.LOG_TASK_NAME, - 'ERROR', - '[Offer Assignment] An error occurred while updating offer assignment email status for offer id ' - '{token_offer} and message id {token_send_id} via the Ecommerce API.'.format( - token_offer=self.OFFER_ASSIGNMENT_ID, - token_send_id=self.SEND_ID - ) - ), - ( - self.LOG_TASK_NAME, - 'ERROR', - '[Offer Assignment] An error occurred while updating email status data for offer {token_offer} and ' - 'email {token_email} via the ecommerce API.'.format( - token_offer=self.OFFER_ASSIGNMENT_ID, - token_email=self.USER_EMAIL - ) - ) - ) - - @responses.activate - @ddt.data( - ( - # Success case - { - 'offer_assignment_id': '555', - 'send_id': '1234ABC', - 'status': 'updated', - 'error': '' - }, - True, - ), - ( - # Exception case - { - 'offer_assignment_id': '555', - 'send_id': '1234ABC', - 'status': 'failed', - 'error': '' - }, - False, - ), - ) - @ddt.unpack - def test_update_assignment_email_status(self, data, return_value): - """ - Test routine that updates email send status in ecommerce. - """ - self.mock_ecommerce_assignmentemail_api(data) - self.assertEqual( - _update_assignment_email_status('555', '1234ABC', 'success'), - return_value - ) diff --git a/ecommerce_worker/sailthru/v1/tests/test_utils.py b/ecommerce_worker/sailthru/v1/tests/test_utils.py deleted file mode 100644 index 0eaf2cf6..00000000 --- a/ecommerce_worker/sailthru/v1/tests/test_utils.py +++ /dev/null @@ -1,69 +0,0 @@ -""" Tests for utility functions. """ - -from __future__ import absolute_import -from unittest import TestCase - -import ddt -import mock - -from ecommerce_worker.sailthru.v1.exceptions import SailthruNotEnabled, ConfigurationError -from ecommerce_worker.sailthru.v1.utils import get_sailthru_client - - -@ddt.ddt -class SailthruUtilsTests(TestCase): - """ Tests for Sailthru utility functions. """ - SITE_CODE = 'test' - SITE_OVERRIDES_MODULE = 'ecommerce_worker.configuration.test.SITE_OVERRIDES' - - def assert_get_sailthru_client_raises(self, exc_class, config): - """ Asserts an error is raised by a call to get_sailthru_client. """ - overrides = { - self.SITE_CODE: { - 'SAILTHRU': config - } - } - with mock.patch.dict(self.SITE_OVERRIDES_MODULE, overrides): - with self.assertRaises(exc_class): - get_sailthru_client(self.SITE_CODE) - - def test_get_sailthru_client_with_sailthru_disabled(self): - """ Verify the method raises a SailthruNotEnabled if Sailthru is not enabled for the site. """ - - with mock.patch('ecommerce_worker.sailthru.v1.utils.log.debug') as mock_log: - self.assert_get_sailthru_client_raises(SailthruNotEnabled, {'SAILTHRU_ENABLE': False}) - - mock_log.assert_called_once_with('Sailthru is not enabled for site {}'.format(self.SITE_CODE)) - - @ddt.data( - {}, - {'SAILTHRU_KEY': None, 'SAILTHRU_SECRET': None}, - {'SAILTHRU_KEY': 'test', 'SAILTHRU_SECRET': None}, - {'SAILTHRU_KEY': None, 'SAILTHRU_SECRET': 'test'}, - ) - def test_get_sailthru_client_without_credentials(self, sailthru_config): - """ Verify the method raises a ConfigurationError if Sailthru is not configured properly. """ - sailthru_config['SAILTHRU_ENABLE'] = True - - with mock.patch('ecommerce_worker.sailthru.v1.utils.log.error') as mock_log: - self.assert_get_sailthru_client_raises(ConfigurationError, sailthru_config) - - mock_log.assert_called_once_with('Both key and secret are required for site {}'.format(self.SITE_CODE)) - - def test_get_sailthru_client(self): - """ Verify the method returns an authenticated SailthruClient. """ - key = 'key' - secret = 'secret' - overrides = { - self.SITE_CODE: { - 'SAILTHRU': { - 'SAILTHRU_ENABLE': True, - 'SAILTHRU_KEY': key, - 'SAILTHRU_SECRET': secret - } - } - } - with mock.patch.dict(self.SITE_OVERRIDES_MODULE, overrides): - client = get_sailthru_client(self.SITE_CODE) - self.assertEqual(client.api_key, key) - self.assertEqual(client.secret, secret) diff --git a/ecommerce_worker/sailthru/v1/utils.py b/ecommerce_worker/sailthru/v1/utils.py deleted file mode 100644 index 70e70757..00000000 --- a/ecommerce_worker/sailthru/v1/utils.py +++ /dev/null @@ -1,71 +0,0 @@ -""" Utility functions. """ - -from __future__ import absolute_import -from celery.utils.log import get_task_logger -from sailthru.sailthru_client import SailthruClient - -from ecommerce_worker.sailthru.v1.exceptions import ConfigurationError, SailthruNotEnabled -from ecommerce_worker.utils import get_configuration - -log = get_task_logger(__name__) - - -def get_sailthru_configuration(site_code): - """ Returns the Sailthru configuration for the specified site. """ - config = get_configuration('SAILTHRU', site_code=site_code) - return config - - -def get_sailthru_client(site_code): - """ - Returns a Sailthru client for the specified site. - - Args: - site_code (str): Site for which the client should be configured. - - Returns: - SailthruClient - - Raises: - SailthruNotEnabled: If Sailthru is not enabled for the specified site. - ConfigurationError: If either the Sailthru API key or secret are not set for the site. - """ - # Get configuration - config = get_sailthru_configuration(site_code) - - # Return if Sailthru integration disabled - if not config.get('SAILTHRU_ENABLE'): - msg = 'Sailthru is not enabled for site {}'.format(site_code) - log.debug(msg) - raise SailthruNotEnabled(msg) - - # Make sure key and secret configured - key = config.get('SAILTHRU_KEY') - secret = config.get('SAILTHRU_SECRET') - - if not (key and secret): - msg = 'Both key and secret are required for site {}'.format(site_code) - log.error(msg) - raise ConfigurationError(msg) - - return SailthruClient(key, secret) - - -def can_retry_sailthru_request(error): - """ Returns True if a Sailthru request and be re-submitted after an error has occurred. - - Responses with the following codes can be retried: - 9: Internal Error - 43: Too many [type] requests this minute to /[endpoint] API - - All other errors are considered failures, that should not be retried. A complete list of error codes is available at - https://getstarted.sailthru.com/new-for-developers-overview/api/api-response-errors/. - - Args: - error (SailthruResponseError) - - Returns: - bool: Indicates if the original request can be retried. - """ - code = error.get_error_code() - return code in (9, 43) diff --git a/requirements/base.in b/requirements/base.in index 79a39778..63f19d8e 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -3,5 +3,4 @@ celery edx-rest-api-client redis -sailthru-client six diff --git a/requirements/base.txt b/requirements/base.txt index 178cd873..512d238e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -4,30 +4,64 @@ # # make upgrade # -amqp==2.6.1 # via kombu -billiard==3.6.3.0 # via celery -celery==4.4.7 # via -c requirements/constraints.txt, -r requirements/base.in -certifi==2020.6.20 # via requests -chardet==3.0.4 # via requests -django-crum==0.7.8 # via edx-django-utils -django-waffle==2.0.0 # via edx-django-utils -django==2.2.17 # via -c requirements/constraints.txt, django-crum, edx-django-utils -edx-django-utils==3.11.0 # via edx-rest-api-client -edx-rest-api-client==5.2.1 # via -r requirements/base.in -idna==2.10 # via requests -kombu==4.6.11 # via celery -newrelic==5.22.1.152 # via edx-django-utils -pbr==5.5.1 # via stevedore -psutil==5.7.3 # via edx-django-utils -pyjwt==1.7.1 # via edx-rest-api-client -pytz==2020.4 # via celery, django -redis==3.5.3 # via -r requirements/base.in -requests==2.24.0 # via edx-rest-api-client, sailthru-client, slumber -sailthru-client==2.3.5 # via -r requirements/base.in -simplejson==3.17.2 # via sailthru-client -six==1.15.0 # via -r requirements/base.in -slumber==0.7.1 # via edx-rest-api-client -sqlparse==0.4.1 # via django -stevedore==3.2.2 # via edx-django-utils -urllib3==1.25.11 # via requests -vine==1.3.0 # via amqp, celery +amqp==2.6.1 + # via kombu +billiard==3.6.4.0 + # via celery +celery==4.4.7 + # via + # -c requirements/constraints.txt + # -r requirements/base.in +certifi==2020.12.5 + # via requests +chardet==4.0.0 + # via requests +django-crum==0.7.9 + # via edx-django-utils +django-waffle==2.1.0 + # via edx-django-utils +django==2.2.22 + # via + # -c requirements/constraints.txt + # django-crum + # edx-django-utils +edx-django-utils==4.0.0 + # via edx-rest-api-client +edx-rest-api-client==5.3.0 + # via -r requirements/base.in +idna==2.10 + # via requests +kombu==4.6.11 + # via celery +newrelic==6.2.0.156 + # via edx-django-utils +pbr==5.6.0 + # via stevedore +psutil==5.8.0 + # via edx-django-utils +pyjwt==2.1.0 + # via edx-rest-api-client +pytz==2021.1 + # via + # celery + # django +redis==3.5.3 + # via -r requirements/base.in +requests==2.25.1 + # via + # edx-rest-api-client + # slumber +six==1.16.0 + # via -r requirements/base.in +slumber==0.7.1 + # via edx-rest-api-client +sqlparse==0.4.1 + # via django +stevedore==3.3.0 + # via edx-django-utils +urllib3==1.26.4 + # via requests +vine==1.3.0 + # via + # amqp + # celery diff --git a/requirements/optional.txt b/requirements/optional.txt index a053824c..6231967a 100644 --- a/requirements/optional.txt +++ b/requirements/optional.txt @@ -4,4 +4,5 @@ # # make upgrade # -newrelic==5.22.1.152 # via -r requirements/optional.in +newrelic==6.2.0.156 + # via -r requirements/optional.in diff --git a/requirements/pip_tools.txt b/requirements/pip_tools.txt index 97062ea1..a2c9f561 100644 --- a/requirements/pip_tools.txt +++ b/requirements/pip_tools.txt @@ -4,8 +4,12 @@ # # make upgrade # -click==7.1.2 # via pip-tools -pip-tools==5.5.0 # via -c requirements/constraints.txt, -r requirements/pip_tools.in +click==7.1.2 + # via pip-tools +pip-tools==5.5.0 + # via + # -c requirements/constraints.txt + # -r requirements/pip_tools.in # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/production.txt b/requirements/production.txt index 40b69b88..38d96b35 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -4,31 +4,104 @@ # # make upgrade # -amqp==2.6.1 # via -r requirements/base.txt, kombu -billiard==3.6.3.0 # via -r requirements/base.txt, celery -celery==4.4.7 # via -c requirements/constraints.txt, -r requirements/base.txt -certifi==2020.6.20 # via -r requirements/base.txt, requests -chardet==3.0.4 # via -r requirements/base.txt, requests -django-crum==0.7.8 # via -r requirements/base.txt, edx-django-utils -django-waffle==2.0.0 # via -r requirements/base.txt, edx-django-utils -django==2.2.17 # via -c requirements/constraints.txt, -r requirements/base.txt, django-crum, edx-django-utils -edx-django-utils==3.11.0 # via -r requirements/base.txt, edx-rest-api-client -edx-rest-api-client==5.2.1 # via -r requirements/base.txt -idna==2.10 # via -r requirements/base.txt, requests -kombu==4.6.11 # via -r requirements/base.txt, celery -newrelic==5.22.1.152 # via -r requirements/base.txt, edx-django-utils -pbr==5.5.1 # via -r requirements/base.txt, stevedore -psutil==5.7.3 # via -r requirements/base.txt, edx-django-utils -pyjwt==1.7.1 # via -r requirements/base.txt, edx-rest-api-client -pytz==2020.4 # via -r requirements/base.txt, celery, django -pyyaml==5.3.1 # via -r requirements/production.in -redis==3.5.3 # via -r requirements/base.txt -requests==2.24.0 # via -r requirements/base.txt, edx-rest-api-client, sailthru-client, slumber -sailthru-client==2.3.5 # via -r requirements/base.txt -simplejson==3.17.2 # via -r requirements/base.txt, sailthru-client -six==1.15.0 # via -r requirements/base.txt -slumber==0.7.1 # via -r requirements/base.txt, edx-rest-api-client -sqlparse==0.4.1 # via -r requirements/base.txt, django -stevedore==3.2.2 # via -r requirements/base.txt, edx-django-utils -urllib3==1.25.11 # via -r requirements/base.txt, requests -vine==1.3.0 # via -r requirements/base.txt, amqp, celery +amqp==2.6.1 + # via + # -r requirements/base.txt + # kombu +billiard==3.6.4.0 + # via + # -r requirements/base.txt + # celery +celery==4.4.7 + # via + # -c requirements/constraints.txt + # -r requirements/base.txt +certifi==2020.12.5 + # via + # -r requirements/base.txt + # requests +chardet==4.0.0 + # via + # -r requirements/base.txt + # requests +django-crum==0.7.9 + # via + # -r requirements/base.txt + # edx-django-utils +django-waffle==2.1.0 + # via + # -r requirements/base.txt + # edx-django-utils +django==2.2.22 + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # django-crum + # edx-django-utils +edx-django-utils==4.0.0 + # via + # -r requirements/base.txt + # edx-rest-api-client +edx-rest-api-client==5.3.0 + # via -r requirements/base.txt +idna==2.10 + # via + # -r requirements/base.txt + # requests +kombu==4.6.11 + # via + # -r requirements/base.txt + # celery +newrelic==6.2.0.156 + # via + # -r requirements/base.txt + # edx-django-utils +pbr==5.6.0 + # via + # -r requirements/base.txt + # stevedore +psutil==5.8.0 + # via + # -r requirements/base.txt + # edx-django-utils +pyjwt==2.1.0 + # via + # -r requirements/base.txt + # edx-rest-api-client +pytz==2021.1 + # via + # -r requirements/base.txt + # celery + # django +pyyaml==5.4.1 + # via -r requirements/production.in +redis==3.5.3 + # via -r requirements/base.txt +requests==2.25.1 + # via + # -r requirements/base.txt + # edx-rest-api-client + # slumber +six==1.16.0 + # via -r requirements/base.txt +slumber==0.7.1 + # via + # -r requirements/base.txt + # edx-rest-api-client +sqlparse==0.4.1 + # via + # -r requirements/base.txt + # django +stevedore==3.3.0 + # via + # -r requirements/base.txt + # edx-django-utils +urllib3==1.26.4 + # via + # -r requirements/base.txt + # requests +vine==1.3.0 + # via + # -r requirements/base.txt + # amqp + # celery diff --git a/requirements/test.txt b/requirements/test.txt index 96b958c0..f5b4e3c1 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -6,187 +6,195 @@ # amqp==2.6.1 # via - # -r base.txt + # -r requirements/base.txt # kombu -astroid==2.3.3 +astroid==2.5.6 # via - # -r test.in + # -r requirements/test.in # pylint # pylint-celery -attrs==20.2.0 +attrs==21.2.0 # via pytest -billiard==3.6.3.0 +billiard==3.6.4.0 # via - # -r base.txt + # -r requirements/base.txt # celery celery==4.4.7 # via - # -c constraints.txt - # -r base.txt -certifi==2020.6.20 + # -c requirements/constraints.txt + # -r requirements/base.txt +certifi==2020.12.5 # via - # -r base.txt + # -r requirements/base.txt # requests -chardet==3.0.4 +chardet==4.0.0 # via - # -r base.txt + # -r requirements/base.txt # requests click-log==0.3.2 # via edx-lint click==7.1.2 # via # click-log + # code-annotations # edx-lint -coverage==5.3 +code-annotations==1.1.1 + # via edx-lint +coverage==5.5 # via - # -r test.in + # -r requirements/test.in # pytest-cov -ddt==1.4.1 - # via -r test.in -django-crum==0.7.8 +ddt==1.4.2 + # via -r requirements/test.in +django-crum==0.7.9 # via - # -r base.txt + # -r requirements/base.txt # edx-django-utils -django-waffle==2.0.0 +django-waffle==2.1.0 # via - # -r base.txt + # -r requirements/base.txt # edx-django-utils -django==2.2.17 # via - # -c constraints.txt - # -r base.txt + # -c requirements/constraints.txt + # -r requirements/base.txt + # code-annotations # django-crum # edx-django-utils -edx-django-utils==3.11.0 + # edx-lint +edx-django-utils==4.0.0 # via - # -r base.txt + # -r requirements/base.txt # edx-rest-api-client -edx-lint==1.5.2 - # via -r test.in -edx-rest-api-client==5.2.1 - # via -r base.txt +edx-lint==5.0.0 + # via -r requirements/test.in +edx-rest-api-client==5.3.0 + # via -r requirements/base.txt idna==2.10 # via - # -r base.txt + # -r requirements/base.txt # requests iniconfig==1.1.1 # via pytest -isort==4.3.21 +isort==5.8.0 # via pylint +jinja2==2.11.3 + # via code-annotations kombu==4.6.11 # via - # -r base.txt + # -r requirements/base.txt # celery -lazy-object-proxy==1.4.3 +lazy-object-proxy==1.6.0 # via astroid +markupsafe==1.1.1 + # via jinja2 mccabe==0.6.1 # via pylint -mock==4.0.2 - # via -r test.in -newrelic==5.22.1.152 +mock==4.0.3 + # via -r requirements/test.in +newrelic==6.2.0.156 # via - # -r base.txt + # -r requirements/base.txt # edx-django-utils -packaging==20.4 +packaging==20.9 # via pytest -pbr==5.5.1 +pbr==5.6.0 # via - # -r base.txt + # -r requirements/base.txt # stevedore pluggy==0.13.1 # via pytest -psutil==5.7.3 +psutil==5.8.0 # via - # -r base.txt + # -r requirements/base.txt # edx-django-utils py==1.10.0 # via pytest -pycodestyle==2.6.0 - # via -r test.in -pyjwt==1.7.1 +pycodestyle==2.7.0 + # via -r requirements/test.in +pyjwt==2.1.0 # via - # -r base.txt + # -r requirements/base.txt # edx-rest-api-client pylint-celery==0.3 # via edx-lint -pylint-django==2.0.11 +pylint-django==2.4.4 # via - # -r test.in + # -r requirements/test.in # edx-lint pylint-plugin-utils==0.6 # via - # -r test.in + # -r requirements/test.in # pylint-celery # pylint-django -pylint==2.4.4 +pylint==2.8.2 # via - # -r test.in + # -r requirements/test.in # edx-lint # pylint-celery # pylint-django # pylint-plugin-utils pyparsing==2.4.7 # via packaging -pytest-cov==2.10.1 - # via -r test.in -pytest==6.1.2 +pytest-cov==2.11.1 + # via -r requirements/test.in +pytest==6.2.4 # via - # -r test.in + # -r requirements/test.in # pytest-cov -pytz==2020.4 +python-slugify==5.0.2 + # via code-annotations +pytz==2021.1 # via - # -r base.txt + # -r requirements/base.txt # celery # django +pyyaml==5.4.1 + # via code-annotations redis==3.5.3 - # via -r base.txt -requests==2.24.0 + # via -r requirements/base.txt +requests==2.25.1 # via - # -r base.txt + # -r requirements/base.txt # edx-rest-api-client # responses - # sailthru-client # slumber -responses==0.12.0 - # via -r test.in -sailthru-client==2.3.5 - # via -r base.txt -simplejson==3.17.2 - # via - # -r base.txt - # sailthru-client -six==1.15.0 - # via - # -r base.txt - # astroid +responses==0.13.3 + # via -r requirements/test.in +six==1.16.0 + # via + # -r requirements/base.txt # edx-lint - # packaging # responses slumber==0.7.1 # via - # -r base.txt + # -r requirements/base.txt # edx-rest-api-client sqlparse==0.4.1 # via - # -r base.txt + # -r requirements/base.txt # django -stevedore==3.2.2 +stevedore==3.3.0 # via - # -r base.txt + # -r requirements/base.txt + # code-annotations # edx-django-utils -testfixtures==6.15.0 - # via -r test.in +testfixtures==6.17.1 + # via -r requirements/test.in +text-unidecode==1.3 + # via python-slugify toml==0.10.2 - # via pytest -urllib3==1.25.11 # via - # -r base.txt + # pylint + # pytest +urllib3==1.26.4 + # via + # -r requirements/base.txt # requests # responses vine==1.3.0 # via - # -r base.txt + # -r requirements/base.txt # amqp # celery -wrapt==1.11.2 +wrapt==1.12.1 # via astroid diff --git a/requirements/tox.txt b/requirements/tox.txt index 9cffdb54..599e5cdf 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -12,7 +12,7 @@ filelock==3.0.12 # via # tox # virtualenv -packaging==20.4 +packaging==20.9 # via tox pluggy==0.13.1 # via tox @@ -20,18 +20,17 @@ py==1.10.0 # via tox pyparsing==2.4.7 # via packaging -six==1.15.0 +six==1.16.0 # via - # packaging # tox # virtualenv toml==0.10.2 # via tox tox-battery==0.6.1 - # via -r tox.in -tox==3.20.1 + # via -r requirements/tox.in +tox==3.23.1 # via - # -r tox.in + # -r requirements/tox.in # tox-battery -virtualenv==20.1.0 +virtualenv==20.4.6 # via tox diff --git a/setup.py b/setup.py index 20b9bd95..05198a1f 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def is_requirement(line): setup( name='edx-ecommerce-worker', - version='1.3.4', + version='2.0.0', description='Celery tasks supporting the operations of edX\'s ecommerce service', long_description=long_description, classifiers=[