""" 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