Merge pull request #27502 from edx/mikix/drop-email-marketing

feat!: remove all email_marketing djangoapp code
This commit is contained in:
Michael Terry
2021-05-04 08:39:08 -04:00
committed by GitHub
22 changed files with 87 additions and 1586 deletions

View File

@@ -93,8 +93,6 @@ log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit")
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore # pylint: disable=invalid-name
# enroll status changed events - signaled to email_marketing. See email_marketing.tasks for more info
# ENROLL signal used for free enrollment only
class EnrollStatusChange:

View File

@@ -4,6 +4,5 @@ from common.djangoapps.student.signals.signals import (
ENROLL_STATUS_CHANGE,
ENROLLMENT_TRACK_UPDATED,
REFUND_ORDER,
SAILTHRU_AUDIT_PURCHASE,
UNENROLL_DONE
)

View File

@@ -9,4 +9,3 @@ ENROLLMENT_TRACK_UPDATED = Signal(providing_args=['user', 'course_key', 'mode',
UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"])
ENROLL_STATUS_CHANGE = Signal(providing_args=["event", "user", "course_id", "mode", "cost", "currency"])
REFUND_ORDER = Signal(providing_args=["course_enrollment"])
SAILTHRU_AUDIT_PURCHASE = Signal(providing_args=["user", "course_id", "mode"])

View File

@@ -12,7 +12,6 @@ from eventtracking import tracker
# The setting name used for events when "settings" (account settings, preferences, profile information) change.
USER_SETTINGS_CHANGED_EVENT_NAME = 'edx.user.settings.changed'
# Used to signal a field value change
USER_FIELD_CHANGED = Signal(providing_args=["user", "table", "setting", "old_value", "new_value"])
USER_FIELDS_CHANGED = Signal(providing_args=["user", "table", "changed_values"])
@@ -153,10 +152,6 @@ def emit_settings_changed_event(user, db_table, changed_fields: Dict[str, Tuple[
truncated_fields
)
# Announce field change
USER_FIELD_CHANGED.send(sender=None, user=user, table=db_table, setting=setting_name,
old_value=old_value, new_value=new_value)
# Announce field change
USER_FIELDS_CHANGED.send(sender=None, user=user, table=db_table, changed_fields=changed_fields)

View File

@@ -129,8 +129,6 @@ What the old imports are, and their replacements:
+-------------------------------+----------------------------------------------+
| ``edxnotes`` | ``lms.djangoapps.edxnotes`` |
+-------------------------------+----------------------------------------------+
| ``email_marketing`` | ``lms.djangoapps.email_marketing`` |
+-------------------------------+----------------------------------------------+
| ``experiments`` | ``lms.djangoapps.experiments`` |
+-------------------------------+----------------------------------------------+
| ``gating`` | ``lms.djangoapps.gating`` |

View File

@@ -14,7 +14,6 @@ Studio.
lms/djangoapps/bulk_email/modules
lms/djangoapps/courseware/modules
lms/djangoapps/coursewarehistoryextended/modules
lms/djangoapps/email_marketing/modules
lms/djangoapps/experiments/modules
lms/djangoapps/lti_provider/modules
lms/djangoapps/mobile_api/modules

View File

@@ -28,7 +28,6 @@ from xmodule.modulestore.tests.factories import CourseFactory
from ....constants import Messages
from ....tests.mocks import mock_basket_order
from ....tests.test_views import UserMixin
from ..views import SAILTHRU_CAMPAIGN_COOKIE
UTM_COOKIE_NAME = 'edx.test.utm'
UTM_COOKIE_CONTENTS = {
@@ -57,7 +56,6 @@ class BasketsViewTests(UserMixin, ModuleStoreTestCase):
if marketing_email_opt_in:
payload["email_opt_in"] = True
self.client.cookies[SAILTHRU_CAMPAIGN_COOKIE] = 'sailthru id'
if include_utm_cookie:
self.client.cookies[UTM_COOKIE_NAME] = json.dumps(UTM_COOKIE_CONTENTS)
return self.client.post(self.url, payload)

View File

@@ -16,7 +16,6 @@ from rest_framework.views import APIView
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.entitlements.models import CourseEntitlement
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.signals import SAILTHRU_AUDIT_PURCHASE
from common.djangoapps.util.json_request import JsonResponse
from lms.djangoapps.courseware import courses
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
@@ -30,7 +29,6 @@ from ...constants import Messages
from ...http import DetailResponse
log = logging.getLogger(__name__)
SAILTHRU_CAMPAIGN_COOKIE = 'sailthru_bid'
class BasketsView(APIView):
@@ -153,9 +151,6 @@ class BasketsView(APIView):
log.info(msg)
self._enroll(course_key, user, default_enrollment_mode.slug)
mode = CourseMode.AUDIT if audit_mode else CourseMode.HONOR
SAILTHRU_AUDIT_PURCHASE.send(
sender=None, user=user, mode=mode, course_id=course_id
)
self._handle_marketing_opt_in(request, course_key, user)
return DetailResponse(msg)
else:

View File

@@ -1,9 +0,0 @@
""" Admin site bindings for email marketing """
from config_models.admin import ConfigurationModelAdmin
from django.contrib import admin
from .models import EmailMarketingConfiguration
admin.site.register(EmailMarketingConfiguration, ConfigurationModelAdmin)

View File

@@ -2,7 +2,6 @@
Configuration for the email_marketing Django application.
"""
from django.apps import AppConfig
@@ -12,7 +11,3 @@ class EmailMarketingConfig(AppConfig):
"""
name = 'lms.djangoapps.email_marketing'
verbose_name = "Email Marketing"
def ready(self):
# Register the signal handlers.
from . import signals # lint-amnesty, pylint: disable=unused-import, unused-variable

View File

@@ -1,320 +0,0 @@
"""
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)

View File

@@ -1,516 +0,0 @@
"""
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

View File

@@ -1,669 +0,0 @@
"""Tests of email marketing signal handlers."""
import datetime
import logging
from unittest.mock import ANY, Mock, patch
import ddt
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.contrib.sites.models import Site
from django.test import TestCase
from django.test.client import RequestFactory
from freezegun import freeze_time
from opaque_keys.edx.keys import CourseKey
from sailthru.sailthru_error import SailthruClientError
from sailthru.sailthru_response import SailthruResponse
from testfixtures import LogCapture
from common.djangoapps.student.models import Registration, User
from common.djangoapps.student.tests.factories import ( # lint-amnesty, pylint: disable=unused-import
CourseEnrollmentFactory,
UserFactory,
UserProfileFactory
)
from common.djangoapps.util.json_request import JsonResponse
from lms.djangoapps.email_marketing.tasks import ( # lint-amnesty, pylint: disable=unused-import
_create_user_list,
_get_list_from_email_marketing_provider,
_get_or_create_user_list,
get_email_cookies_via_sailthru,
update_user,
update_user_email
)
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from ..models import EmailMarketingConfiguration
from ..signals import (
add_email_marketing_cookies,
email_marketing_register_user,
email_marketing_user_field_changed,
update_sailthru
)
log = logging.getLogger(__name__)
LOGGER_NAME = "lms.djangoapps.email_marketing.signals"
TEST_EMAIL = "test@edx.org"
def update_email_marketing_config(enabled=True, key='badkey', secret='badsecret', new_user_list='new list',
template='Welcome', enroll_cost=100, lms_url_override='http://testserver'):
"""
Enable / Disable Sailthru integration
"""
return EmailMarketingConfiguration.objects.create(
enabled=enabled,
sailthru_key=key,
sailthru_secret=secret,
sailthru_new_user_list=new_user_list,
sailthru_welcome_template=template,
sailthru_enroll_template='enroll_template',
sailthru_lms_url_override=lms_url_override,
sailthru_get_tags_from_sailthru=False,
sailthru_enroll_cost=enroll_cost,
sailthru_max_retries=0,
welcome_email_send_delay=600
)
@ddt.ddt
class EmailMarketingTests(TestCase):
"""
Tests for the EmailMarketing signals and tasks classes.
"""
def setUp(self):
update_email_marketing_config(enabled=False)
self.request_factory = RequestFactory()
self.user = UserFactory.create(username='test', email=TEST_EMAIL)
self.registration = Registration()
self.registration.register(self.user)
self.request = self.request_factory.get("foo")
update_email_marketing_config(enabled=True)
# create some test course objects
self.course_id_string = 'edX/toy/2012_Fall'
self.course_id = CourseKey.from_string(self.course_id_string)
self.course_url = 'http://testserver/courses/edX/toy/2012_Fall/info'
self.site = Site.objects.get_current()
self.request.site = self.site
super().setUp()
@freeze_time(datetime.datetime.now())
@patch('lms.djangoapps.email_marketing.signals.crum.get_current_request')
@patch('sailthru.sailthru_client.SailthruClient.api_post')
def test_drop_cookie(self, mock_sailthru, mock_get_current_request):
"""
Test add_email_marketing_cookies
"""
response = JsonResponse({
"success": True,
"redirect_url": 'test.com/test',
})
self.request.COOKIES['anonymous_interest'] = 'cookie_content'
mock_get_current_request.return_value = self.request
cookies = {'cookie': 'test_cookie'}
mock_sailthru.return_value = SailthruResponse(JsonResponse({'keys': cookies}))
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
add_email_marketing_cookies(None, response=response, user=self.user)
logger.check(
(LOGGER_NAME, 'INFO',
'Started at {start} and ended at {end}, time spent:{delta} milliseconds'.format(
start=datetime.datetime.now().isoformat(' '),
end=datetime.datetime.now().isoformat(' '),
delta=0.0)
),
(LOGGER_NAME, 'INFO',
'sailthru_hid cookie:{cookies[cookie]} successfully retrieved for user {user}'.format(
cookies=cookies,
user=TEST_EMAIL)
)
)
mock_sailthru.assert_called_with('user',
{'fields': {'keys': 1},
'cookies': {'anonymous_interest': 'cookie_content'},
'id': TEST_EMAIL,
'vars': {'last_login_date': ANY}})
assert 'sailthru_hid' in response.cookies
assert response.cookies['sailthru_hid'].value == 'test_cookie'
@patch('sailthru.sailthru_client.SailthruClient.api_post')
def test_get_cookies_via_sailthu(self, mock_sailthru):
cookies = {'cookie': 'test_cookie'}
mock_sailthru.return_value = SailthruResponse(JsonResponse({'keys': cookies}))
post_parms = {
'id': self.user.email,
'fields': {'keys': 1},
'vars': {'last_login_date': datetime.datetime.now().strftime("%Y-%m-%d")},
'cookies': {'anonymous_interest': 'cookie_content'}
}
expected_cookie = get_email_cookies_via_sailthru.delay(self.user.email, post_parms)
mock_sailthru.assert_called_with('user',
{'fields': {'keys': 1},
'cookies': {'anonymous_interest': 'cookie_content'},
'id': TEST_EMAIL,
'vars': {'last_login_date': ANY}})
assert cookies['cookie'] == expected_cookie.result
@patch('sailthru.sailthru_client.SailthruClient.api_post')
def test_drop_cookie_error_path(self, mock_sailthru):
"""
test that error paths return no cookie
"""
response = JsonResponse({
"success": True,
"redirect_url": 'test.com/test',
})
mock_sailthru.return_value = SailthruResponse(JsonResponse({'keys': {'cookiexx': 'test_cookie'}}))
add_email_marketing_cookies(None, response=response, user=self.user)
assert 'sailthru_hid' not in response.cookies
mock_sailthru.return_value = SailthruResponse(JsonResponse({'error': "error", "errormsg": "errormsg"}))
add_email_marketing_cookies(None, response=response, user=self.user)
assert 'sailthru_hid' not in response.cookies
mock_sailthru.side_effect = SailthruClientError
add_email_marketing_cookies(None, response=response, user=self.user)
assert 'sailthru_hid' not in response.cookies
@patch('lms.djangoapps.email_marketing.tasks.log.error')
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_get')
def test_add_user(self, mock_sailthru_get, mock_sailthru_post, mock_log_error):
"""
test async method in tasks that actually updates Sailthru
"""
site_dict = {'id': self.site.id, 'domain': self.site.domain, 'name': self.site.name}
mock_sailthru_post.return_value = SailthruResponse(JsonResponse({'ok': True}))
mock_sailthru_get.return_value = SailthruResponse(JsonResponse({'lists': [{'name': 'new list'}], 'ok': True}))
update_user.delay(
{'gender': 'm', 'username': 'test', 'activated': 1}, TEST_EMAIL, site_dict, new_user=True
)
assert not mock_log_error.called
assert mock_sailthru_post.call_args[0][0] == 'user'
userparms = mock_sailthru_post.call_args[0][1]
assert userparms['key'] == 'email'
assert userparms['id'] == TEST_EMAIL
assert userparms['vars']['gender'] == 'm'
assert userparms['vars']['username'] == 'test'
assert userparms['vars']['activated'] == 1
assert userparms['lists']['new list'] == 1
@patch('lms.djangoapps.email_marketing.signals.get_email_cookies_via_sailthru.delay')
def test_drop_cookie_task_error(self, mock_email_cookies):
"""
Tests that task error is handled
"""
mock_email_cookies.return_value = {}
mock_email_cookies.get.side_effect = Exception
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
add_email_marketing_cookies(None, response=None, user=self.user)
logger.check((
LOGGER_NAME, 'ERROR', 'Exception Connecting to celery task for {}'.format(
self.user.email
)
))
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
def test_email_not_sent_to_enterprise_learners(self, mock_sailthru_post):
"""
tests that welcome email is not sent to the enterprise learner
"""
mock_sailthru_post.return_value = SailthruResponse(JsonResponse({'ok': True}))
update_user.delay(
sailthru_vars={
'is_enterprise_learner': True,
'enterprise_name': 'test name',
},
email=self.user.email
)
assert mock_sailthru_post.call_args[0][0] != 'send'
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
def test_add_user_list_not_called_on_white_label_domain(self, mock_sailthru_post):
"""
test user is not added to Sailthru user lists if registered from a whitel labe site
"""
existing_site = Site.objects.create(domain='testwhitelabel.com', name='White Label')
site_dict = {'id': existing_site.id, 'domain': existing_site.domain, 'name': existing_site.name}
update_user.delay(
{'gender': 'm', 'username': 'test', 'activated': 1}, TEST_EMAIL, site=site_dict, new_user=True
)
assert not mock_sailthru_post.called
@patch('lms.djangoapps.email_marketing.tasks.log.error')
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
def test_update_user_error_logging(self, mock_sailthru, mock_log_error):
"""
Ensure that error returned from Sailthru api is logged
"""
mock_sailthru.return_value = SailthruResponse(JsonResponse({'error': 100, 'errormsg': 'Got an error'}))
update_user.delay({}, self.user.email)
assert mock_log_error.called
# force Sailthru API exception
mock_log_error.reset_mock()
mock_sailthru.side_effect = SailthruClientError
update_user.delay({}, self.user.email)
assert mock_log_error.called
# force Sailthru API exception on 2nd call
mock_log_error.reset_mock()
mock_sailthru.side_effect = [SailthruResponse(JsonResponse({'ok': True})), SailthruClientError]
update_user.delay({}, self.user.email, activation=True)
assert mock_log_error.called
# force Sailthru API error return on 2nd call
mock_log_error.reset_mock()
mock_sailthru.side_effect = [SailthruResponse(JsonResponse({'ok': True})),
SailthruResponse(JsonResponse({'error': 100, 'errormsg': 'Got an error'}))]
update_user.delay({}, self.user.email, activation=True)
assert mock_log_error.called
@patch('lms.djangoapps.email_marketing.tasks.update_user.retry')
@patch('lms.djangoapps.email_marketing.tasks.log.error')
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
def test_update_user_error_retryable(self, mock_sailthru, mock_log_error, mock_retry):
"""
Ensure that retryable error is retried
"""
mock_sailthru.return_value = SailthruResponse(JsonResponse({'error': 43, 'errormsg': 'Got an error'}))
update_user.delay({}, self.user.email)
assert mock_log_error.called
assert mock_retry.called
@patch('lms.djangoapps.email_marketing.tasks.update_user.retry')
@patch('lms.djangoapps.email_marketing.tasks.log.error')
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
def test_update_user_error_nonretryable(self, mock_sailthru, mock_log_error, mock_retry):
"""
Ensure that non-retryable error is not retried
"""
mock_sailthru.return_value = SailthruResponse(JsonResponse({'error': 1, 'errormsg': 'Got an error'}))
update_user.delay({}, self.user.email)
assert mock_log_error.called
assert not mock_retry.called
@patch('lms.djangoapps.email_marketing.tasks.log.error')
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
def test_just_return_tasks(self, mock_sailthru, mock_log_error):
"""
Ensure that disabling Sailthru just returns
"""
update_email_marketing_config(enabled=False)
update_user.delay(self.user.username, self.user.email)
assert not mock_log_error.called
assert not mock_sailthru.called
update_user_email.delay(self.user.username, "newemail2@test.com")
assert not mock_log_error.called
assert not mock_sailthru.called
update_email_marketing_config(enabled=True)
@patch('lms.djangoapps.email_marketing.signals.log.error')
def test_just_return_signals(self, mock_log_error):
"""
Ensure that disabling Sailthru just returns
"""
update_email_marketing_config(enabled=False)
add_email_marketing_cookies(None)
assert not mock_log_error.called
email_marketing_register_user(None, None, None)
assert not mock_log_error.called
update_email_marketing_config(enabled=True)
# test anonymous users
anon = AnonymousUser()
email_marketing_register_user(None, anon, None)
assert not mock_log_error.called
email_marketing_user_field_changed(None, user=anon)
assert not mock_log_error.called
user = User(username='test', email='test@example.com')
email_marketing_user_field_changed(None, user=user)
assert not mock_log_error.called
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
def test_change_email(self, mock_sailthru):
"""
test async method in task that changes email in Sailthru
"""
mock_sailthru.return_value = SailthruResponse(JsonResponse({'ok': True}))
update_user_email.delay(TEST_EMAIL, "old@edx.org")
assert mock_sailthru.call_args[0][0] == 'user'
userparms = mock_sailthru.call_args[0][1]
assert userparms['key'] == 'email'
assert userparms['id'] == 'old@edx.org'
assert userparms['keys']['email'] == TEST_EMAIL
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient')
def test_get_or_create_sailthru_list(self, mock_sailthru_client):
"""
Test the task the create sailthru lists.
"""
mock_sailthru_client.api_get.return_value = SailthruResponse(JsonResponse({'lists': []}))
_get_or_create_user_list(mock_sailthru_client, 'test1_user_list')
mock_sailthru_client.api_get.assert_called_with("list", {})
mock_sailthru_client.api_post.assert_called_with(
"list", {'list': 'test1_user_list', 'primary': 0, 'public_name': 'test1_user_list'}
)
# test existing user list
mock_sailthru_client.api_get.return_value = \
SailthruResponse(JsonResponse({'lists': [{'name': 'test1_user_list'}]}))
_get_or_create_user_list(mock_sailthru_client, 'test2_user_list')
mock_sailthru_client.api_get.assert_called_with("list", {})
mock_sailthru_client.api_post.assert_called_with(
"list", {'list': 'test2_user_list', 'primary': 0, 'public_name': 'test2_user_list'}
)
# test get error from Sailthru
mock_sailthru_client.api_get.return_value = \
SailthruResponse(JsonResponse({'error': 43, 'errormsg': 'Got an error'}))
assert _get_or_create_user_list(mock_sailthru_client, 'test1_user_list') is None
# test post error from Sailthru
mock_sailthru_client.api_post.return_value = \
SailthruResponse(JsonResponse({'error': 43, 'errormsg': 'Got an error'}))
mock_sailthru_client.api_get.return_value = SailthruResponse(JsonResponse({'lists': []}))
assert _get_or_create_user_list(mock_sailthru_client, 'test2_user_list') is None
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient')
def test_get_sailthru_list_map_no_list(self, mock_sailthru_client):
"""Test when no list returned from sailthru"""
mock_sailthru_client.api_get.return_value = SailthruResponse(JsonResponse({'lists': []}))
assert _get_list_from_email_marketing_provider(mock_sailthru_client) == {}
mock_sailthru_client.api_get.assert_called_with("list", {})
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient')
def test_get_sailthru_list_map_error(self, mock_sailthru_client):
"""Test when error occurred while fetching data from sailthru"""
mock_sailthru_client.api_get.return_value = SailthruResponse(
JsonResponse({'error': 43, 'errormsg': 'Got an error'})
)
assert _get_list_from_email_marketing_provider(mock_sailthru_client) == {}
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient')
def test_get_sailthru_list_map_exception(self, mock_sailthru_client):
"""Test when exception raised while fetching data from sailthru"""
mock_sailthru_client.api_get.side_effect = SailthruClientError
assert _get_list_from_email_marketing_provider(mock_sailthru_client) == {}
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient')
def test_get_sailthru_list(self, mock_sailthru_client):
"""Test fetch list data from sailthru"""
mock_sailthru_client.api_get.return_value = \
SailthruResponse(JsonResponse({'lists': [{'name': 'test1_user_list'}]}))
assert _get_list_from_email_marketing_provider(mock_sailthru_client) == {
'test1_user_list': {'name': 'test1_user_list'}
}
mock_sailthru_client.api_get.assert_called_with("list", {})
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient')
def test_create_sailthru_list(self, mock_sailthru_client):
"""Test create list in sailthru"""
mock_sailthru_client.api_post.return_value = SailthruResponse(JsonResponse({'ok': True}))
assert _create_user_list(mock_sailthru_client, 'test_list_name') is True
assert mock_sailthru_client.api_post.call_args[0][0] == 'list'
listparms = mock_sailthru_client.api_post.call_args[0][1]
assert listparms['list'] == 'test_list_name'
assert listparms['primary'] == 0
assert listparms['public_name'] == 'test_list_name'
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient')
def test_create_sailthru_list_error(self, mock_sailthru_client):
"""Test error occurrence while creating sailthru list"""
mock_sailthru_client.api_post.return_value = SailthruResponse(
JsonResponse({'error': 43, 'errormsg': 'Got an error'})
)
assert _create_user_list(mock_sailthru_client, 'test_list_name') is False
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient')
def test_create_sailthru_list_exception(self, mock_sailthru_client):
"""Test exception raised while creating sailthru list"""
mock_sailthru_client.api_post.side_effect = SailthruClientError
assert _create_user_list(mock_sailthru_client, 'test_list_name') is False
@patch('lms.djangoapps.email_marketing.tasks.log.error')
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
def test_error_logging(self, mock_sailthru, mock_log_error):
"""
Ensure that error returned from Sailthru api is logged
"""
mock_sailthru.return_value = SailthruResponse(JsonResponse({'error': 100, 'errormsg': 'Got an error'}))
update_user_email.delay(self.user.username, "newemail2@test.com")
assert mock_log_error.called
mock_sailthru.side_effect = SailthruClientError
update_user_email.delay(self.user.username, "newemail2@test.com")
assert mock_log_error.called
@patch('lms.djangoapps.email_marketing.signals.crum.get_current_request')
@patch('lms.djangoapps.email_marketing.tasks.update_user.delay')
def test_register_user(self, mock_update_user, mock_get_current_request):
"""
make sure register user call invokes update_user and includes activation_key
"""
mock_get_current_request.return_value = self.request
email_marketing_register_user(None, user=self.user, registration=self.registration)
assert mock_update_user.called
assert mock_update_user.call_args[0][0]['activation_key'] == self.registration.activation_key
assert mock_update_user.call_args[0][0]['signupNumber'] <= 9
@patch('lms.djangoapps.email_marketing.tasks.update_user.delay')
def test_register_user_no_request(self, mock_update_user):
"""
make sure register user call invokes update_user and includes activation_key
"""
email_marketing_register_user(None, user=self.user, registration=self.registration)
assert mock_update_user.called
assert mock_update_user.call_args[0][0]['activation_key'] == self.registration.activation_key
@patch('lms.djangoapps.email_marketing.tasks.update_user.delay')
def test_register_user_language_preference(self, mock_update_user):
"""
make sure register user call invokes update_user and includes language preference
"""
# If the user hasn't set an explicit language preference, we should send the application's default.
assert self.user.preferences.model.get_value(self.user, LANGUAGE_KEY) is None
email_marketing_register_user(None, user=self.user, registration=self.registration)
assert mock_update_user.call_args[0][0]['ui_lang'] == settings.LANGUAGE_CODE
# If the user has set an explicit language preference, we should send it.
self.user.preferences.create(key=LANGUAGE_KEY, value='es-419')
email_marketing_register_user(None, user=self.user, registration=self.registration)
assert mock_update_user.call_args[0][0]['ui_lang'] == 'es-419'
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@patch('lms.djangoapps.email_marketing.signals.crum.get_current_request')
@patch('lms.djangoapps.email_marketing.tasks.update_user.delay')
@ddt.data(('auth_userprofile', 'gender', 'f', True),
('auth_user', 'is_active', 1, True),
('auth_userprofile', 'shoe_size', 1, False),
('user_api_userpreference', 'pref-lang', 'en', True))
@ddt.unpack
def test_modify_field(self, table, setting, value, result, mock_update_user, mock_get_current_request):
"""
Test that correct fields call update_user
"""
mock_get_current_request.return_value = self.request
email_marketing_user_field_changed(None, self.user, table=table, setting=setting, new_value=value)
assert mock_update_user.called == result
@patch('lms.djangoapps.email_marketing.tasks.SailthruClient.api_post')
@patch('lms.djangoapps.email_marketing.signals.third_party_auth.provider.Registry.get_from_pipeline')
@patch('lms.djangoapps.email_marketing.signals.third_party_auth.pipeline.get')
@patch('lms.djangoapps.email_marketing.signals.crum.get_current_request')
@ddt.data(True, False)
def test_modify_field_with_sso(self, send_welcome_email, mock_get_current_request,
mock_pipeline_get, mock_registry_get_from_pipeline, mock_sailthru_post):
"""
Test that welcome email is sent appropriately in the context of SSO registration
"""
mock_get_current_request.return_value = self.request
mock_pipeline_get.return_value = 'saml-idp'
mock_registry_get_from_pipeline.return_value = Mock(send_welcome_email=send_welcome_email)
mock_sailthru_post.return_value = SailthruResponse(JsonResponse({'ok': True}))
email_marketing_user_field_changed(None, self.user, table='auth_user', setting='is_active', new_value=True)
if send_welcome_email:
assert mock_sailthru_post.call_args[0][0] == 'send'
else:
assert mock_sailthru_post.call_args[0][0] != 'send'
@patch('lms.djangoapps.email_marketing.tasks.update_user.delay')
def test_modify_language_preference(self, mock_update_user):
"""
Test that update_user is called with new language preference
"""
# If the user hasn't set an explicit language preference, we should send the application's default.
assert self.user.preferences.model.get_value(self.user, LANGUAGE_KEY) is None
email_marketing_user_field_changed(
None, self.user, table='user_api_userpreference', setting=LANGUAGE_KEY, new_value=None
)
assert mock_update_user.call_args[0][0]['ui_lang'] == settings.LANGUAGE_CODE
# If the user has set an explicit language preference, we should send it.
self.user.preferences.create(key=LANGUAGE_KEY, value='fr')
email_marketing_user_field_changed(
None, self.user, table='user_api_userpreference', setting=LANGUAGE_KEY, new_value='fr'
)
assert mock_update_user.call_args[0][0]['ui_lang'] == 'fr'
@patch('lms.djangoapps.email_marketing.tasks.update_user_email.delay')
def test_modify_email(self, mock_update_user):
"""
Test that change to email calls update_user_email
"""
email_marketing_user_field_changed(None, self.user, table='auth_user', setting='email', old_value='new@a.com')
mock_update_user.assert_called_with(self.user.email, 'new@a.com')
# make sure nothing called if disabled
mock_update_user.reset_mock()
update_email_marketing_config(enabled=False)
email_marketing_user_field_changed(None, self.user, table='auth_user', setting='email', old_value='new@a.com')
assert not mock_update_user.called
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().setUp()
self.user = UserFactory()
self.course_id = CourseKey.from_string('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'
@patch('sailthru.sailthru_client.SailthruClient.purchase')
@patch('sailthru.sailthru_client.SailthruClient.api_get')
@patch('sailthru.sailthru_client.SailthruClient.api_post')
@patch('edx_toggles.toggles.LegacyWaffleSwitchNamespace.is_enabled')
def test_update_course_enrollment_whitelabel(
self,
switch,
mock_sailthru_api_post,
mock_sailthru_api_get,
mock_sailthru_purchase
):
"""test user record not sent to sailthru when enrolled in a course at white label site"""
switch.return_value = True
white_label_site = Site.objects.create(domain='testwhitelabel.com', name='White Label')
site_dict = {'id': white_label_site.id, 'domain': white_label_site.domain, 'name': white_label_site.name}
with patch('lms.djangoapps.email_marketing.signals._get_current_site') as mock_site_info:
mock_site_info.return_value = site_dict
update_sailthru(None, self.user, 'audit', str(self.course_id))
assert not mock_sailthru_purchase.called
assert not mock_sailthru_api_post.called
assert not mock_sailthru_api_get.called
@patch('sailthru.sailthru_client.SailthruClient.purchase')
def test_switch_is_disabled(self, mock_sailthru_purchase):
"""Make sure sailthru purchase is not called when waffle switch is disabled"""
update_sailthru(None, self.user, 'verified', self.course_id)
assert not mock_sailthru_purchase.called
@patch('edx_toggles.toggles.LegacyWaffleSwitchNamespace.is_enabled')
@patch('sailthru.sailthru_client.SailthruClient.purchase')
def test_purchase_is_not_invoked(self, mock_sailthru_purchase, switch):
"""Make sure purchase is not called in the following condition:
i: waffle switch is True and mode is verified
"""
switch.return_value = True
update_sailthru(None, self.user, 'verified', self.course_id)
assert not mock_sailthru_purchase.called
@patch('edx_toggles.toggles.LegacyWaffleSwitchNamespace.is_enabled')
@patch('sailthru.sailthru_client.SailthruClient.purchase')
def test_encoding_is_working_for_email_contains_unicode(self, mock_sailthru_purchase, switch):
"""Make sure encoding is working for emails contains unicode characters
while sending it to sail through.
"""
switch.return_value = True
self.user.email = 'tèst@edx.org'
update_sailthru(None, self.user, 'audit', str(self.course_id))
assert mock_sailthru_purchase.called

View File

@@ -1621,9 +1621,8 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase, TestVerification
mock.Mock(side_effect=mocked_has_valid_signature)
)
@patch('lms.djangoapps.verify_student.views.log.error')
@patch('sailthru.sailthru_client.SailthruClient.send')
@patch('lms.djangoapps.verify_student.views.segment.track')
def test_passed_status_template(self, mock_segment_track, _mock_sailthru_send, _mock_log_error):
def test_passed_status_template(self, mock_segment_track, _mock_log_error):
"""
Test for verification passed.
"""
@@ -1694,9 +1693,8 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase, TestVerification
mock.Mock(side_effect=mocked_has_valid_signature)
)
@patch('lms.djangoapps.verify_student.views.log.error')
@patch('sailthru.sailthru_client.SailthruClient.send')
@patch('lms.djangoapps.verify_student.views.segment.track')
def test_first_time_verification(self, mock_segment_track, mock_sailthru_send, mock_log_error): # pylint: disable=unused-argument
def test_first_time_verification(self, mock_segment_track, _mock_log_error):
"""
Test for verification passed if the learner does not have any previous verification
"""
@@ -1736,9 +1734,8 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase, TestVerification
mock.Mock(side_effect=mocked_has_valid_signature)
)
@patch('lms.djangoapps.verify_student.views.log.error')
@patch('sailthru.sailthru_client.SailthruClient.send')
@patch('lms.djangoapps.verify_student.views.segment.track')
def test_failed_status_template(self, mock_segment_track, _mock_sailthru_send, _mock_log_error):
def test_failed_status_template(self, mock_segment_track, _mock_log_error):
"""
Test for failed verification.
"""

View File

@@ -1007,14 +1007,6 @@ EXPLICIT_QUEUES = {
'queue': GRADES_DOWNLOAD_ROUTING_KEY},
'lms.djangoapps.instructor_task.tasks.generate_certificates': {
'queue': GRADES_DOWNLOAD_ROUTING_KEY},
'lms.djangoapps.email_marketing.tasks.get_email_cookies_via_sailthru': {
'queue': ACE_ROUTING_KEY},
'lms.djangoapps.email_marketing.tasks.update_user': {
'queue': ACE_ROUTING_KEY},
'lms.djangoapps.email_marketing.tasks.update_user_email': {
'queue': ACE_ROUTING_KEY},
'lms.djangoapps.email_marketing.tasks.update_course_enrollment': {
'queue': ACE_ROUTING_KEY},
'lms.djangoapps.verify_student.tasks.send_verification_status_email': {
'queue': ACE_ROUTING_KEY},
'lms.djangoapps.verify_student.tasks.send_ace_message': {

View File

@@ -3,7 +3,7 @@ User Authentication Configuration
"""
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs
from edx_django_utils.plugins import PluginSignals, PluginURLs
from openedx.core.djangoapps.plugins.constants import ProjectType
@@ -22,4 +22,14 @@ class UserAuthnConfig(AppConfig):
PluginURLs.RELATIVE_PATH: 'urls',
},
},
PluginSignals.CONFIG: {
ProjectType.LMS: {
PluginSignals.RECEIVERS: [
{
PluginSignals.RECEIVER_FUNC_NAME: 'user_fields_changed',
PluginSignals.SIGNAL_PATH: 'common.djangoapps.util.model_utils.USER_FIELDS_CHANGED',
},
],
},
},
}

View File

@@ -0,0 +1,43 @@
"""
Signals for user_authn
"""
from typing import Any, Dict, Optional, Tuple
from common.djangoapps.student.models import UserProfile
from common.djangoapps.track import segment
def user_fields_changed(
user=None,
table=None,
changed_fields: Optional[Dict[str, Tuple[Any, Any]]] = None,
**_kwargs,
):
"""
Update a collection of user profile fields in segment when they change in the database
Args:
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.
"""
fields = {field: new_value for (field, (old_value, new_value)) in changed_fields.items()}
# This mirrors the logic in ./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
)

View File

@@ -0,0 +1,30 @@
"""Tests for user_authn signals"""
from unittest.mock import patch
from django.test import TestCase
from common.djangoapps.student.tests.factories import UserFactory, UserProfileFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
@skip_unless_lms
class UserAuthnSignalTests(TestCase):
"""Tests for signal handlers"""
def test_identify_call_on_user_change(self):
user = UserFactory()
with patch('openedx.core.djangoapps.user_authn.signals.segment') as mock_segment:
user.email = 'user@example.com'
user.save()
assert mock_segment.identify.call_count == 1
assert mock_segment.identify.call_args[0] == (user.id, {'email': 'user@example.com'})
def test_identify_call_on_profile_change(self):
profile = UserProfileFactory(user=UserFactory())
with patch('openedx.core.djangoapps.user_authn.signals.segment') as mock_segment:
profile.gender = 'f'
profile.save()
assert mock_segment.identify.call_count == 1
assert mock_segment.identify.call_args[0] == (profile.user_id, {'gender': 'Female'})

View File

@@ -16,7 +16,6 @@ from integrated_channels.integrated_channel.tasks import (
)
from slumber.exceptions import HttpClientError
from lms.djangoapps.email_marketing.tasks import update_user
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_NOW_PASSED, COURSE_ASSESSMENT_GRADE_CHANGED
from openedx.features.enterprise_support.api import enterprise_enabled
@@ -27,23 +26,6 @@ from common.djangoapps.student.signals import UNENROLL_DONE
log = logging.getLogger(__name__)
@receiver(post_save, sender=EnterpriseCustomerUser)
def update_email_marketing_user_with_enterprise_vars(sender, instance, **kwargs): # pylint: disable=unused-argument, invalid-name
"""
Update the SailThru user with enterprise-related vars.
"""
user = User.objects.get(id=instance.user_id)
# perform update asynchronously
update_user.delay(
sailthru_vars={
'is_enterprise_learner': True,
'enterprise_name': instance.enterprise_customer.name,
},
email=user.email
)
@receiver(post_save, sender=EnterpriseCourseEnrollment)
def update_dsc_cache_on_course_enrollment(sender, instance, **kwargs): # pylint: disable=unused-argument
"""

View File

@@ -77,20 +77,6 @@ class EnterpriseSupportSignals(SharedModuleStoreTestCase):
enterprise_customer_user=enterprise_customer_user,
)
@patch('openedx.features.enterprise_support.signals.update_user.delay')
def test_register_user(self, mock_update_user):
"""
make sure marketing enterprise user call invokes update_user
"""
self._create_enterprise_enrollment(self.user.id, self.course_id)
mock_update_user.assert_called_with(
sailthru_vars={
'is_enterprise_learner': True,
'enterprise_name': self.enterprise_customer.name,
},
email=self.user.email
)
def test_signal_update_dsc_cache_on_course_enrollment(self):
"""
make sure update_dsc_cache_on_course_enrollment signal clears cache when Enterprise Course Enrollment

View File

@@ -146,7 +146,6 @@ requests-oauthlib # Simplifies use of OAuth via the requests l
random2
rules # Django extension for rules-based authorization checks
simplejson
sailthru-client # For Sailthru integration
Shapely # Geometry library, used for image click regions in capa
six # Utilities for supporting Python 2 & 3 in the same codebase
social-auth-app-django