""" This module contains signals needed for email integration """ import datetime import logging from random import randint from typing import Any, Dict, Optional, Tuple import crum from celery.exceptions import TimeoutError as CeleryTimeoutError from django.conf import settings from django.dispatch import receiver from edx_toggles.toggles import LegacyWaffleSwitchNamespace from sailthru.sailthru_error import SailthruClientError from common.djangoapps import third_party_auth from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.helpers import does_user_profile_exist from common.djangoapps.student.models import UserProfile from common.djangoapps.student.signals import SAILTHRU_AUDIT_PURCHASE from common.djangoapps.track import segment from common.djangoapps.util.model_utils import USER_FIELD_CHANGED, USER_FIELDS_CHANGED from lms.djangoapps.email_marketing.tasks import ( get_email_cookies_via_sailthru, update_course_enrollment, update_user, update_user_email ) from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.user_authn.cookies import CREATE_LOGON_COOKIE from openedx.core.djangoapps.user_authn.views.register import REGISTER_USER from .models import EmailMarketingConfiguration log = logging.getLogger(__name__) # list of changed fields to pass to Sailthru CHANGED_FIELDNAMES = ['username', 'is_active', 'name', 'gender', 'education', 'age', 'level_of_education', 'year_of_birth', 'country', LANGUAGE_KEY] # TODO: Remove in AA-607 WAFFLE_NAMESPACE = 'sailthru' WAFFLE_SWITCHES = LegacyWaffleSwitchNamespace(name=WAFFLE_NAMESPACE) # TODO: Remove in AA-607 SAILTHRU_AUDIT_PURCHASE_ENABLED = 'audit_purchase_enabled' # TODO: Remove in AA-607 @receiver(SAILTHRU_AUDIT_PURCHASE) def update_sailthru(sender, user, mode, course_id, **kwargs): # pylint: disable=unused-argument """ Receives signal and calls a celery task to update the enrollment track Arguments: user: current user course_id: course key of a course Returns: None """ if WAFFLE_SWITCHES.is_enabled(SAILTHRU_AUDIT_PURCHASE_ENABLED) and mode in CourseMode.AUDIT_MODES: email = user.email.encode('utf-8') update_course_enrollment.delay(email, course_id, mode, site=_get_current_site()) # TODO: Remove in AA-607 @receiver(CREATE_LOGON_COOKIE) def add_email_marketing_cookies(sender, response=None, user=None, **kwargs): # pylint: disable=unused-argument """ Signal function for adding any cookies needed for email marketing Args: response: http response object user: The user object for the user being changed Returns: response: http response object with cookie added """ email_config = EmailMarketingConfiguration.current() if not email_config.enabled: return response post_parms = { 'id': user.email, 'fields': {'keys': 1}, 'vars': {'last_login_date': datetime.datetime.now().strftime("%Y-%m-%d")} } # get anonymous_interest cookie to capture usage before logon request = crum.get_current_request() if request: sailthru_content = request.COOKIES.get('anonymous_interest') if sailthru_content: post_parms['cookies'] = {'anonymous_interest': sailthru_content} time_before_call = datetime.datetime.now() sailthru_response = get_email_cookies_via_sailthru.delay(user.email, post_parms) try: # synchronous call to get result of an asynchronous celery task, with timeout sailthru_response.get(timeout=email_config.user_registration_cookie_timeout_delay, propagate=True) cookie = sailthru_response.result _log_sailthru_api_call_time(time_before_call) except CeleryTimeoutError as exc: log.error("Timeout error while attempting to obtain cookie from Sailthru: %s", str(exc)) return response except SailthruClientError as exc: log.error("Exception attempting to obtain cookie from Sailthru: %s", str(exc)) return response except Exception: # lint-amnesty, pylint: disable=broad-except log.error("Exception Connecting to celery task for %s", user.email) return response if not cookie: log.error("No cookie returned attempting to obtain cookie from Sailthru for %s", user.email) return response else: response.set_cookie( 'sailthru_hid', cookie, max_age=365 * 24 * 60 * 60, # set for 1 year domain=settings.SESSION_COOKIE_DOMAIN, path='/', secure=request.is_secure() ) log.info("sailthru_hid cookie:%s successfully retrieved for user %s", cookie, user.email) return response # TODO: Remove in AA-607 @receiver(REGISTER_USER) def email_marketing_register_user(sender, user, registration, **kwargs): # pylint: disable=unused-argument """ Called after user created and saved Args: sender: Not used user: The user object for the user being changed registration: The user registration profile to activate user account kwargs: Not used """ email_config = EmailMarketingConfiguration.current() if not email_config.enabled: return # ignore anonymous users if user.is_anonymous: return # perform update asynchronously update_user.delay(_create_sailthru_user_vars(user, user.profile, registration=registration), user.email, site=_get_current_site(), new_user=True) # TODO: Remove in AA-607 @receiver(USER_FIELD_CHANGED) def email_marketing_user_field_changed(sender, user=None, table=None, setting=None, old_value=None, new_value=None, **kwargs): # pylint: disable=unused-argument """ Update a single user/profile field Args: sender: Not used user: The user object for the user being changed table: The name of the table being updated setting: The name of the setting being updated old_value: Prior value new_value: New value kwargs: Not used """ # ignore anonymous users if user.is_anonymous: return # Ignore users that do not yet have a profile if not does_user_profile_exist(user): return # ignore anything but User, Profile or UserPreference tables if table not in {'auth_user', 'auth_userprofile', 'user_api_userpreference'}: return # ignore anything not in list of fields to handle if setting in CHANGED_FIELDNAMES: # skip if not enabled # the check has to be here rather than at the start of the method to avoid # accessing the config during migration 0001_date__add_ecommerce_service_user email_config = EmailMarketingConfiguration.current() if not email_config.enabled: return # Is the status of the user account changing to active? is_activation = (setting == 'is_active') and new_value is True # Is this change in the context of an SSO-initiated registration? third_party_provider = None if third_party_auth.is_enabled(): running_pipeline = third_party_auth.pipeline.get(crum.get_current_request()) if running_pipeline: third_party_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) # Send a welcome email if the user account is being activated # and we are not in a SSO registration flow whose associated # identity provider is configured to allow for the sending # of a welcome email. send_welcome_email = is_activation and ( third_party_provider is None or third_party_provider.send_welcome_email ) # set the activation flag when the user is marked as activated update_user.delay(_create_sailthru_user_vars(user, user.profile), user.email, site=_get_current_site(), new_user=False, activation=send_welcome_email) elif setting == 'email': # email update is special case email_config = EmailMarketingConfiguration.current() if not email_config.enabled: return update_user_email.delay(user.email, old_value) @receiver(USER_FIELDS_CHANGED) def email_marketing_user_fields_changed( sender, # pylint: disable=unused-argument user=None, table=None, changed_fields: Optional[Dict[str, Tuple[Any, Any]]] = None, **kwargs ): """ Update a collection of user profile fields Args: sender: Not used user: The user object for the user being changed table: The name of the table being updated changed_fields: A mapping from changed field name to old and new values. kwargs: Not used """ fields = {field: new_value for (field, (old_value, new_value)) in changed_fields.items()} # This mirrors the logic in openedx/core/djangoapps/user_authn/views/register.py:_track_user_registration if table == 'auth_userprofile': if 'gender' in fields and fields['gender']: fields['gender'] = dict(UserProfile.GENDER_CHOICES)[fields['gender']] if 'country' in fields: fields['country'] = str(fields['country']) if 'level_of_education' in fields and fields['level_of_education']: fields['education'] = dict(UserProfile.LEVEL_OF_EDUCATION_CHOICES)[fields['level_of_education']] if 'year_of_birth' in fields: fields['yearOfBirth'] = fields.pop('year_of_birth') if 'mailing_address' in fields: fields['address'] = fields.pop('mailing_address') segment.identify( user.id, fields ) def _create_sailthru_user_vars(user, profile, registration=None): """ Create sailthru user create/update vars from user + profile. """ sailthru_vars = {'username': user.username, 'activated': int(user.is_active), 'joined_date': user.date_joined.strftime("%Y-%m-%d")} # Set the ui_lang to the User's prefered language, if specified. Otherwise use the application's default language. sailthru_vars['ui_lang'] = user.preferences.model.get_value(user, LANGUAGE_KEY, default=settings.LANGUAGE_CODE) if profile: sailthru_vars['fullname'] = profile.name sailthru_vars['gender'] = profile.gender sailthru_vars['education'] = profile.level_of_education if profile.year_of_birth: sailthru_vars['year_of_birth'] = profile.year_of_birth sailthru_vars['country'] = str(profile.country.code) if registration: sailthru_vars['activation_key'] = registration.activation_key sailthru_vars['signupNumber'] = randint(0, 9) return sailthru_vars def _get_current_site(): """ Returns the site for the current request if any. """ request = crum.get_current_request() if not request: return return {'id': request.site.id, 'domain': request.site.domain, 'name': request.site.name} def _log_sailthru_api_call_time(time_before_call): """ Logs Sailthru api synchronous call time """ time_after_call = datetime.datetime.now() delta_sailthru_api_call_time = time_after_call - time_before_call log.info("Started at %s and ended at %s, time spent:%s milliseconds", time_before_call.isoformat(' '), time_after_call.isoformat(' '), delta_sailthru_api_call_time.microseconds / 1000)