517 lines
18 KiB
Python
517 lines
18 KiB
Python
"""
|
|
This file contains celery tasks for email marketing signal handler.
|
|
"""
|
|
|
|
|
|
import logging
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
|
|
from celery import shared_task
|
|
from django.conf import settings
|
|
from django.core.cache import cache
|
|
from edx_django_utils.monitoring import set_code_owner_attribute
|
|
from sailthru.sailthru_client import SailthruClient
|
|
from sailthru.sailthru_error import SailthruClientError
|
|
|
|
from .models import EmailMarketingConfiguration
|
|
|
|
log = logging.getLogger(__name__)
|
|
SAILTHRU_LIST_CACHE_KEY = "email.marketing.cache"
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
@shared_task(bind=True)
|
|
@set_code_owner_attribute
|
|
def get_email_cookies_via_sailthru(self, user_email, post_parms): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
Adds/updates Sailthru cookie information for a new user.
|
|
Args:
|
|
post_parms(dict): User profile information to pass as 'vars' to Sailthru
|
|
Returns:
|
|
cookie(str): cookie fetched from Sailthru
|
|
"""
|
|
|
|
email_config = EmailMarketingConfiguration.current()
|
|
if not email_config.enabled:
|
|
return None
|
|
|
|
try:
|
|
sailthru_client = SailthruClient(email_config.sailthru_key, email_config.sailthru_secret)
|
|
log.info(
|
|
'Sending to Sailthru the user interest cookie [%s] for user [%s]',
|
|
post_parms.get('cookies', ''),
|
|
user_email
|
|
)
|
|
sailthru_response = sailthru_client.api_post("user", post_parms)
|
|
except SailthruClientError as exc:
|
|
log.error("Exception attempting to obtain cookie from Sailthru: %s", str(exc))
|
|
raise SailthruClientError # lint-amnesty, pylint: disable=raise-missing-from
|
|
|
|
if sailthru_response.is_ok():
|
|
if 'keys' in sailthru_response.json and 'cookie' in sailthru_response.json['keys']:
|
|
cookie = sailthru_response.json['keys']['cookie']
|
|
return cookie
|
|
else:
|
|
log.error("No cookie returned attempting to obtain cookie from Sailthru for %s", user_email)
|
|
else:
|
|
error = sailthru_response.get_error()
|
|
# generally invalid email address
|
|
log.info("Error attempting to obtain cookie from Sailthru: %s", error.get_message())
|
|
|
|
return None
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
@shared_task(bind=True, default_retry_delay=3600, max_retries=24)
|
|
@set_code_owner_attribute
|
|
def update_user(self, sailthru_vars, email, site=None, new_user=False, activation=False):
|
|
"""
|
|
Adds/updates Sailthru profile information for a user.
|
|
Args:
|
|
sailthru_vars(dict): User profile information to pass as 'vars' to Sailthru
|
|
email(str): User email address
|
|
new_user(boolean): True if new registration
|
|
activation(boolean): True if a welcome email should be sent
|
|
Returns:
|
|
None
|
|
"""
|
|
email_config = EmailMarketingConfiguration.current()
|
|
if not email_config.enabled:
|
|
return
|
|
|
|
# do not add user if registered at a white label site
|
|
if not is_default_site(site):
|
|
return
|
|
|
|
sailthru_client = SailthruClient(email_config.sailthru_key, email_config.sailthru_secret)
|
|
try:
|
|
sailthru_response = sailthru_client.api_post("user",
|
|
_create_email_user_param(sailthru_vars, sailthru_client,
|
|
email, new_user, email_config,
|
|
site=site))
|
|
|
|
except SailthruClientError as exc:
|
|
log.error("Exception attempting to add/update user %s in Sailthru - %s", email, str(exc))
|
|
raise self.retry(exc=exc,
|
|
countdown=email_config.sailthru_retry_interval,
|
|
max_retries=email_config.sailthru_max_retries)
|
|
|
|
if not sailthru_response.is_ok():
|
|
error = sailthru_response.get_error()
|
|
log.error("Error attempting to add/update user in Sailthru: %s", error.get_message())
|
|
if _retryable_sailthru_error(error):
|
|
raise self.retry(countdown=email_config.sailthru_retry_interval,
|
|
max_retries=email_config.sailthru_max_retries)
|
|
return
|
|
|
|
if activation and email_config.sailthru_welcome_template and not sailthru_vars.get('is_enterprise_learner'):
|
|
|
|
scheduled_datetime = datetime.utcnow() + timedelta(seconds=email_config.welcome_email_send_delay)
|
|
try:
|
|
sailthru_response = sailthru_client.api_post(
|
|
"send",
|
|
{
|
|
"email": email,
|
|
"template": email_config.sailthru_welcome_template,
|
|
"schedule_time": scheduled_datetime.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
}
|
|
)
|
|
except SailthruClientError as exc:
|
|
log.error(
|
|
"Exception attempting to send welcome email to user %s in Sailthru - %s",
|
|
email,
|
|
str(exc)
|
|
)
|
|
raise self.retry(exc=exc,
|
|
countdown=email_config.sailthru_retry_interval,
|
|
max_retries=email_config.sailthru_max_retries)
|
|
|
|
if not sailthru_response.is_ok():
|
|
error = sailthru_response.get_error()
|
|
log.error("Error attempting to send welcome email to user in Sailthru: %s", error.get_message())
|
|
if _retryable_sailthru_error(error):
|
|
raise self.retry(countdown=email_config.sailthru_retry_interval,
|
|
max_retries=email_config.sailthru_max_retries)
|
|
|
|
|
|
def is_default_site(site):
|
|
"""
|
|
Checks whether the site is a default site or a white-label
|
|
Args:
|
|
site: A dict containing the site info
|
|
Returns:
|
|
Boolean
|
|
"""
|
|
return not site or site.get('id') == settings.SITE_ID
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
@shared_task(bind=True, default_retry_delay=3600, max_retries=24)
|
|
@set_code_owner_attribute
|
|
def update_user_email(self, new_email, old_email):
|
|
"""
|
|
Adds/updates Sailthru when a user email address is changed
|
|
Args:
|
|
username(str): A string representation of user identifier
|
|
old_email(str): Original email address
|
|
Returns:
|
|
None
|
|
"""
|
|
email_config = EmailMarketingConfiguration.current()
|
|
if not email_config.enabled:
|
|
return
|
|
|
|
# ignore if email not changed
|
|
if new_email == old_email:
|
|
return
|
|
|
|
sailthru_parms = {"id": old_email, "key": "email", "keysconflict": "merge", "keys": {"email": new_email}}
|
|
|
|
try:
|
|
sailthru_client = SailthruClient(email_config.sailthru_key, email_config.sailthru_secret)
|
|
sailthru_response = sailthru_client.api_post("user", sailthru_parms)
|
|
except SailthruClientError as exc:
|
|
log.error("Exception attempting to update email for %s in Sailthru - %s", old_email, str(exc))
|
|
raise self.retry(exc=exc,
|
|
countdown=email_config.sailthru_retry_interval,
|
|
max_retries=email_config.sailthru_max_retries)
|
|
|
|
if not sailthru_response.is_ok():
|
|
error = sailthru_response.get_error()
|
|
log.error("Error attempting to update user email address in Sailthru: %s", error.get_message())
|
|
if _retryable_sailthru_error(error):
|
|
raise self.retry(countdown=email_config.sailthru_retry_interval,
|
|
max_retries=email_config.sailthru_max_retries)
|
|
|
|
|
|
def _create_email_user_param(sailthru_vars, sailthru_client, email, new_user, email_config, site=None):
|
|
"""
|
|
Create sailthru user create/update parms
|
|
"""
|
|
sailthru_user = {'id': email, 'key': 'email'}
|
|
sailthru_user['vars'] = dict(sailthru_vars, last_changed_time=int(time.time()))
|
|
|
|
# if new user add to list
|
|
if new_user:
|
|
list_name = _get_or_create_user_list_for_site(
|
|
sailthru_client, site=site, default_list_name=email_config.sailthru_new_user_list
|
|
)
|
|
sailthru_user['lists'] = {list_name: 1} if list_name else {email_config.sailthru_new_user_list: 1}
|
|
|
|
return sailthru_user
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
def _get_or_create_user_list_for_site(sailthru_client, site=None, default_list_name=None):
|
|
"""
|
|
Get the user list name from cache if exists else create one and return the name,
|
|
callers of this function should perform the enabled check of email config.
|
|
:param: sailthru_client
|
|
:param: site
|
|
:param: default_list_name
|
|
:return: list name if exists or created else return None
|
|
"""
|
|
if not is_default_site(site):
|
|
list_name = site.get('domain', '').replace(".", "_") + "_user_list"
|
|
else:
|
|
list_name = default_list_name
|
|
|
|
sailthru_list = _get_or_create_user_list(sailthru_client, list_name)
|
|
return list_name if sailthru_list else default_list_name
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
def _get_or_create_user_list(sailthru_client, list_name):
|
|
"""
|
|
Get list from sailthru and return if list_name exists else create a new one
|
|
and return list data for all lists.
|
|
:param sailthru_client
|
|
:param list_name
|
|
:return sailthru list
|
|
"""
|
|
sailthru_list_cache = cache.get(SAILTHRU_LIST_CACHE_KEY)
|
|
is_cache_updated = False
|
|
if not sailthru_list_cache:
|
|
sailthru_list_cache = _get_list_from_email_marketing_provider(sailthru_client)
|
|
is_cache_updated = True
|
|
|
|
sailthru_list = sailthru_list_cache.get(list_name)
|
|
|
|
if not sailthru_list:
|
|
is_created = _create_user_list(sailthru_client, list_name)
|
|
if is_created:
|
|
sailthru_list_cache = _get_list_from_email_marketing_provider(sailthru_client)
|
|
is_cache_updated = True
|
|
sailthru_list = sailthru_list_cache.get(list_name)
|
|
|
|
if is_cache_updated:
|
|
cache.set(SAILTHRU_LIST_CACHE_KEY, sailthru_list_cache)
|
|
|
|
return sailthru_list
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
def _get_list_from_email_marketing_provider(sailthru_client):
|
|
"""
|
|
Get sailthru list
|
|
:param sailthru_client
|
|
:return dict of sailthru lists mapped by list name
|
|
"""
|
|
try:
|
|
sailthru_get_response = sailthru_client.api_get("list", {})
|
|
except SailthruClientError as exc:
|
|
log.error("Exception attempting to get list from Sailthru - %s", str(exc))
|
|
return {}
|
|
|
|
if not sailthru_get_response.is_ok():
|
|
error = sailthru_get_response.get_error()
|
|
log.info("Error attempting to read list record from Sailthru: %s", error.get_message())
|
|
return {}
|
|
|
|
list_map = dict()
|
|
for user_list in sailthru_get_response.json['lists']:
|
|
list_map[user_list.get('name')] = user_list
|
|
|
|
return list_map
|
|
|
|
|
|
def _create_user_list(sailthru_client, list_name):
|
|
"""
|
|
Create list in Sailthru
|
|
:param sailthru_client
|
|
:param list_name
|
|
:return boolean
|
|
"""
|
|
list_params = {'list': list_name, 'primary': 0, 'public_name': list_name}
|
|
try:
|
|
sailthru_response = sailthru_client.api_post("list", list_params)
|
|
except SailthruClientError as exc:
|
|
log.error("Exception attempting to list record for key %s in Sailthru - %s", list_name, str(exc))
|
|
return False
|
|
|
|
if not sailthru_response.is_ok():
|
|
error = sailthru_response.get_error()
|
|
log.error("Error attempting to create list in Sailthru: %s", error.get_message())
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
def _retryable_sailthru_error(error):
|
|
""" Return True if error should be retried.
|
|
|
|
9: Retryable internal error
|
|
43: Rate limiting response
|
|
others: Not retryable
|
|
|
|
See: https://getstarted.sailthru.com/new-for-developers-overview/api/api-response-errors/
|
|
"""
|
|
code = error.get_error_code()
|
|
return code == 9 or code == 43 # lint-amnesty, pylint: disable=consider-using-in
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
@shared_task(bind=True)
|
|
@set_code_owner_attribute
|
|
def update_course_enrollment(self, email, course_key, mode, site=None):
|
|
"""Adds/updates Sailthru when a user adds to cart/purchases/upgrades a course
|
|
Args:
|
|
email: email address of enrolled user
|
|
course_key: course key of course
|
|
mode: mode user is enrolled in
|
|
site: site where user enrolled
|
|
Returns:
|
|
None
|
|
"""
|
|
# do not add user if registered at a white label site
|
|
if not is_default_site(site):
|
|
return
|
|
|
|
course_url = build_course_url(course_key)
|
|
config = EmailMarketingConfiguration.current()
|
|
|
|
try:
|
|
sailthru_client = SailthruClient(config.sailthru_key, config.sailthru_secret)
|
|
except: # lint-amnesty, pylint: disable=bare-except
|
|
return
|
|
|
|
send_template = config.sailthru_enroll_template
|
|
cost_in_cents = 0
|
|
|
|
if not update_unenrolled_list(sailthru_client, email, course_url, False):
|
|
schedule_retry(self, config)
|
|
|
|
course_data = _get_course_content(course_key, course_url, sailthru_client, config)
|
|
|
|
item = _build_purchase_item(course_key, course_url, cost_in_cents, mode, course_data)
|
|
options = {}
|
|
|
|
if send_template:
|
|
options['send_template'] = send_template
|
|
|
|
if not _record_purchase(sailthru_client, email, item, options):
|
|
schedule_retry(self, config)
|
|
|
|
|
|
def build_course_url(course_key):
|
|
"""
|
|
Generates and return url of the course info page by using course_key
|
|
Arguments:
|
|
course_key: course_key of the given course
|
|
Returns
|
|
a complete url of the course info page
|
|
"""
|
|
return '{base_url}/courses/{course_key}/info'.format(base_url=settings.LMS_ROOT_URL,
|
|
course_key=str(course_key))
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
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: 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()
|
|
log.error("Error attempting to read user record from Sailthru: %s", error.get_message())
|
|
return not _retryable_sailthru_error(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()
|
|
log.error("Error attempting to update user record in Sailthru: %s", error.get_message())
|
|
return not _retryable_sailthru_error(error)
|
|
|
|
return True
|
|
|
|
except SailthruClientError as exc:
|
|
log.exception("Exception attempting to update user record for %s in Sailthru - %s", email, str(exc))
|
|
return False
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
def schedule_retry(self, config):
|
|
"""Schedule a retry"""
|
|
raise self.retry(countdown=config.sailthru_retry_interval,
|
|
max_retries=config.sailthru_max_retries)
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
def _get_course_content(course_id, course_url, sailthru_client, 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 : SailthruClient
|
|
config : config options
|
|
Returns:
|
|
course information from Sailthru
|
|
"""
|
|
# check cache first
|
|
|
|
cache_key = f"{course_id}:{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():
|
|
log.error('Could not get course data from Sailthru on enroll/unenroll event. ')
|
|
response = {}
|
|
else:
|
|
response = sailthru_response.json
|
|
cache.set(cache_key, response, config.sailthru_content_cache_age)
|
|
|
|
except SailthruClientError:
|
|
response = {}
|
|
|
|
return response
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
def _build_purchase_item(course_id, course_url, cost_in_cents, mode, course_data):
|
|
"""Build and return Sailthru purchase item object"""
|
|
|
|
# build item description
|
|
item = {
|
|
'id': f"{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'] = f'Course {course_id} mode: {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=str(course_id))
|
|
|
|
return item
|
|
|
|
|
|
# TODO: Remove in AA-607
|
|
def _record_purchase(sailthru_client, email, item, options):
|
|
"""
|
|
Record a purchase in Sailthru
|
|
Arguments:
|
|
sailthru_client: SailthruClient
|
|
email: user's email address
|
|
item: Sailthru required information
|
|
options: Sailthru purchase API options
|
|
Returns:
|
|
False if retryable error, else True
|
|
"""
|
|
|
|
try:
|
|
sailthru_response = sailthru_client.purchase(email, [item], options=options)
|
|
|
|
if not sailthru_response.is_ok():
|
|
error = sailthru_response.get_error()
|
|
log.error("Error attempting to record purchase in Sailthru: %s", error.get_message())
|
|
return not _retryable_sailthru_error(error)
|
|
|
|
except SailthruClientError as exc:
|
|
log.exception("Exception attempting to record purchase for %s in Sailthru - %s", email, str(exc))
|
|
return False
|
|
return True
|