Skip to content

Commit

Permalink
feat: use edx-braze-client for sending offer usage email
Browse files Browse the repository at this point in the history
* Install edx-braze-client and create a new EdxBrazeClient class with the library client class as its base.
* Use the new client for sending API-triggered campaign messages for the Enterprise Offer Usage email.
* The send_offer_usage_email_via_braze task now takes a dict of {email: lms_user_id} instead of a simple list of email addresses as its first parameter.
* Attempt to create_recipient with the lms user ID as the external ID for each email to which this message is sent.
ENT-5940
  • Loading branch information
iloveagent57 committed Jun 29, 2022
1 parent 6a07fd9 commit 4f94680
Show file tree
Hide file tree
Showing 12 changed files with 469 additions and 202 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ target/
# Vim
*.swp

# Emacs
*~

# Local configuration overrides
private.py

Expand Down
12 changes: 7 additions & 5 deletions ecommerce_worker/configuration/devstack.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from logging.config import dictConfig
import os

import yaml

Expand All @@ -15,12 +16,13 @@
dictConfig(logger_config)
# END LOGGING

filename = get_overrides_filename('ECOMMERCE_WORKER_CFG')
with open(filename) as f:
config_from_yaml = yaml.load(f)
if not os.environ.get('IGNORE_YAML_OVERRIDES'):
filename = get_overrides_filename('ECOMMERCE_WORKER_CFG')
with open(filename) as f:
config_from_yaml = yaml.load(f)

# Override base configuration with values from disk.
vars().update(config_from_yaml)
# Override base configuration with values from disk.
vars().update(config_from_yaml)

# Apply any developer-defined overrides.
try:
Expand Down
121 changes: 85 additions & 36 deletions ecommerce_worker/email/v1/braze/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import requests
from celery.utils.log import get_task_logger

from braze import client as edx_braze_client

from ecommerce_worker.email.v1.braze.exceptions import (
ConfigurationError,
BrazeNotEnabled,
Expand All @@ -22,7 +24,7 @@
log = get_task_logger(__name__)


def is_braze_enabled(site_code) -> bool: # pylint: disable=missing-function-docstring
def is_braze_enabled(site_code) -> bool:
config = get_braze_configuration(site_code)
return bool(config.get('BRAZE_ENABLE'))

Expand All @@ -38,6 +40,23 @@ def get_braze_configuration(site_code):
return config


def validate_braze_config(config, site_code):
"""
Raises if braze is not enabled or if either the
Rest or Webapp API keys are missing from the configuration.
"""
# Return if Braze integration disabled
if not config.get('BRAZE_ENABLE'):
msg = f'Braze is not enabled for site {site_code}'
log.error(msg)
raise BrazeNotEnabled(msg)

if not (config.get('BRAZE_REST_API_KEY') and config.get('BRAZE_WEBAPP_API_KEY')):
msg = f'Required keys missing for site {site_code}'
log.error(msg)
raise ConfigurationError(msg)


def get_braze_client(site_code):
"""
Returns a Braze client for the specified site.
Expand All @@ -52,44 +71,21 @@ def get_braze_client(site_code):
BrazeNotEnabled: If Braze is not enabled for the specified site.
ConfigurationError: If either the Braze API key or Webapp key are not set for the site.
"""
# Get configuration
config = get_braze_configuration(site_code)

# Return if Braze integration disabled
if not config.get('BRAZE_ENABLE'):
msg = f'Braze is not enabled for site {site_code}'
log.debug(msg)
raise BrazeNotEnabled(msg)

rest_api_key = config.get('BRAZE_REST_API_KEY')
webapp_api_key = config.get('BRAZE_WEBAPP_API_KEY')
rest_api_url = config.get('REST_API_URL')
messages_send_endpoint = config.get('MESSAGES_SEND_ENDPOINT')
email_bounce_endpoint = config.get('EMAIL_BOUNCE_ENDPOINT')
new_alias_endpoint = config.get('NEW_ALIAS_ENDPOINT')
users_track_endpoint = config.get('USERS_TRACK_ENDPOINT')
export_id_endpoint = config.get('EXPORT_ID_ENDPOINT')
campaign_send_endpoint = config.get('CAMPAIGN_SEND_ENDPOINT')
enterprise_campaign_id = config.get('ENTERPRISE_CAMPAIGN_ID')
from_email = config.get('FROM_EMAIL')

if not rest_api_key or not webapp_api_key:
msg = f'Required keys missing for site {site_code}'
log.error(msg)
raise ConfigurationError(msg)
validate_braze_config(config, site_code)

return BrazeClient(
rest_api_key=rest_api_key,
webapp_api_key=webapp_api_key,
rest_api_url=rest_api_url,
messages_send_endpoint=messages_send_endpoint,
email_bounce_endpoint=email_bounce_endpoint,
new_alias_endpoint=new_alias_endpoint,
users_track_endpoint=users_track_endpoint,
export_id_endpoint=export_id_endpoint,
campaign_send_endpoint=campaign_send_endpoint,
enterprise_campaign_id=enterprise_campaign_id,
from_email=from_email,
rest_api_key=config.get('BRAZE_REST_API_KEY'),
webapp_api_key=config.get('BRAZE_WEBAPP_API_KEY'),
rest_api_url=config.get('REST_API_URL'),
messages_send_endpoint=config.get('MESSAGES_SEND_ENDPOINT'),
email_bounce_endpoint=config.get('EMAIL_BOUNCE_ENDPOINT'),
new_alias_endpoint=config.get('NEW_ALIAS_ENDPOINT'),
users_track_endpoint=config.get('USERS_TRACK_ENDPOINT'),
export_id_endpoint=config.get('EXPORT_ID_ENDPOINT'),
campaign_send_endpoint=config.get('CAMPAIGN_SEND_ENDPOINT'),
enterprise_campaign_id=config.get('ENTERPRISE_CAMPAIGN_ID'),
from_email=config.get('FROM_EMAIL'),
)


Expand Down Expand Up @@ -526,3 +522,56 @@ def get_braze_external_id(
return response["users"][0]["external_id"]

return None


class EdxBrazeClient(edx_braze_client.BrazeClient):
"""
Wrapper around the edx-braze-client library BrazeClient class.
TODO: Deprecate ``BrazeClient`` above and use only this class
for Braze interactions.
"""
def __init__(self, site_code):
config = get_braze_configuration(site_code)
validate_braze_config(config, site_code)

super().__init__(
api_key=config.get('BRAZE_REST_API_KEY'),
api_url=config.get('REST_API_URL'),
app_id=config.get('BRAZE_WEBAPP_API_KEY'),
)

def create_recipient(
self,
user_email,
lms_user_id,
trigger_properties=None,
):
"""
Create a recipient object using the given user_email and lms_user_id.
"""

user_alias = {
'alias_label': 'Enterprise',
'alias_name': user_email,
}

# Identify the user alias in case it already exists. This is necessary so
# we don't accidently create a duplicate Braze profile.
self.identify_users([{
'external_id': lms_user_id,
'user_alias': user_alias,
}])

attributes = {
"user_alias": user_alias,
"email": user_email,
"_update_existing_only": False,
}

return {
'external_user_id': lms_user_id,
'attributes': attributes,
# If a profile does not already exist, Braze will create a new profile before sending a message.
'send_to_existing_only': False,
'trigger_properties': trigger_properties or {},
}
69 changes: 46 additions & 23 deletions ecommerce_worker/email/v1/braze/tasks.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
"""
This file contains celery task functionality for braze.
"""
from operator import itemgetter

import braze.exceptions as edx_braze_exceptions
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.client import (
get_braze_client,
get_braze_configuration,
EdxBrazeClient,
)
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__)

# Use a smaller countdown for the offer usage task,
# since the mgmt command that executes it blocks until the task is done/failed.
OFFER_USAGE_RETRY_DELAY_SECONDS = 10


def send_offer_assignment_email_via_braze(self, user_email, offer_assignment_id, subject, email_body, sender_alias,
reply_to, attachments, site_code):
Expand Down Expand Up @@ -105,41 +115,54 @@ def send_offer_update_email_via_braze(self, user_email, subject, email_body, sen
)


def send_offer_usage_email_via_braze(self, emails, subject, email_body, reply_to, attachments, site_code):
def send_offer_usage_email_via_braze(
self, lms_user_ids_by_email, subject, email_body_variables, site_code, campaign_id=None
):
"""
Sends the offer usage email via braze.
Args:
self: Ignore.
emails (str): comma separated emails.
lms_user_ids_by_email (dict): Map of recipient email addresses to LMS user ids.
subject (str): Email subject.
email_body (str): The body of the email.
reply_to (str): Enterprise Customer reply to address for email reply.
attachments (list): File attachment list with dicts having 'file_name' and 'url' keys.
email_body_variables (dict): key-value pairs that are injected into Braze email template for personalization.
site_code (str): Identifier of the site sending the email.
campaign_id (str): Identifier of Braze API-triggered campaign to send message through; defaults
to config.ENTERPRISE_CODE_USAGE_CAMPAIGN_ID
"""
config = get_braze_configuration(site_code)
try:
user_emails = list(emails.strip().split(","))
braze_client = get_braze_client(site_code)
_send_braze_message(
braze_client,
email_ids=user_emails,
subject=subject,
body=email_body,
reply_to=reply_to,
attachments=attachments,
campaign_id=config.get('ENTERPRISE_CODE_USAGE_CAMPAIGN_ID'),
message_variation_id=config.get('ENTERPRISE_CODE_USAGE_MESSAGE_VARIATION_ID'),
)
except (BrazeRateLimitError, BrazeInternalServerError) as exc:
raise self.retry(countdown=config.get('BRAZE_RETRY_SECONDS'),
max_retries=config.get('BRAZE_RETRY_ATTEMPTS')) from exc
except BrazeError:
braze_client = EdxBrazeClient(site_code)

message_kwargs = {
'campaign_id': campaign_id or config.get('ENTERPRISE_CODE_USAGE_CAMPAIGN_ID'),
'recipients': [],
'emails': [],
'trigger_properties': {
'subject': subject,
**email_body_variables,
},
}

for user_email, lms_user_id in sorted(lms_user_ids_by_email.items(), key=itemgetter(0)):
if lms_user_id:
recipient = braze_client.create_recipient(user_email, lms_user_id)
message_kwargs['recipients'].append(recipient)
else:
message_kwargs['emails'].append(user_email)

braze_client.send_campaign_message(**message_kwargs)
except (edx_braze_exceptions.BrazeRateLimitError, edx_braze_exceptions.BrazeInternalServerError) as exc:
raise self.retry(
countdown=OFFER_USAGE_RETRY_DELAY_SECONDS,
max_retries=config.get('BRAZE_RETRY_ATTEMPTS')
) from exc
except edx_braze_exceptions.BrazeError:
logger.exception(
'[Offer Usage] Error in offer usage notification with message --- '
'{message}'.format(message=email_body)
'{message}'.format(message=email_body_variables)
)
raise


def send_code_assignment_nudge_email_via_braze(self, email, subject, email_body, sender_alias, reply_to, # pylint: disable=invalid-name
Expand Down
Loading

0 comments on commit 4f94680

Please sign in to comment.