Commit f5b42c89 by PaulWattenberger Committed by GitHub

Initial lms changes for Sailthru (#12706)

* Initial lms changes for Sailthru

* Fix identified code quality issues

* Fix migration failure

* Fix code quality identified issue
parent 14fcb3a1
......@@ -5,10 +5,14 @@ Utility functions for setting "logged in" cookies used by subdomains.
import time
import json
from django.dispatch import Signal
from django.utils.http import cookie_date
from django.conf import settings
from django.core.urlresolvers import reverse, NoReverseMatch
CREATE_LOGON_COOKIE = Signal(providing_args=["user", "response"])
def set_logged_in_cookies(request, response, user):
"""
......@@ -118,6 +122,9 @@ def set_logged_in_cookies(request, response, user):
**cookie_settings
)
# give signal receivers a chance to add cookies
CREATE_LOGON_COOKIE.send(sender=None, user=user, response=response)
return response
......
......@@ -34,7 +34,7 @@ from django.utils.translation import ugettext as _, get_language
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from django.views.decorators.http import require_POST, require_GET
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.dispatch import receiver, Signal
from django.template.response import TemplateResponse
from ratelimitbackend.exceptions import RateLimitException
......@@ -135,6 +135,8 @@ ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number d
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
# Used as the name of the user attribute for tracking affiliate registrations
REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id'
# used to announce a registration
REGISTER_USER = Signal(providing_args=["user", "profile"])
# Disable this warning because it doesn't make sense to completely refactor tests to appease Pylint
# pylint: disable=logging-format-interpolation
......@@ -1754,6 +1756,9 @@ def create_account_with_params(request, params):
}
)
# Announce registration
REGISTER_USER.send(sender=None, user=user, profile=profile)
create_comments_service_user(user)
# Don't send email if we are:
......
......@@ -9,11 +9,14 @@ from eventtracking import tracker
from django.conf import settings
from django.utils.encoding import force_unicode
from django.utils.safestring import mark_safe
from django.dispatch import Signal
from django_countries.fields import Country
# The setting name used for events when "settings" (account settings, preferences, profile information) change.
USER_SETTINGS_CHANGED_EVENT_NAME = u'edx.user.settings.changed'
# Used to signal a field value change
USER_FIELD_CHANGED = Signal(providing_args=["user", "table", "setting", "old_value", "new_value"])
def get_changed_fields_dict(instance, model_class):
......@@ -152,6 +155,10 @@ def emit_setting_changed_event(user, db_table, setting_name, old_value, new_valu
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)
def _get_truncated_setting_value(value, max_length=None):
"""
......
""" Admin site bindings for email marketing """
from django.contrib import admin
from email_marketing.models import EmailMarketingConfiguration
from config_models.admin import ConfigurationModelAdmin
admin.site.register(EmailMarketingConfiguration, ConfigurationModelAdmin)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmailMarketingConfiguration',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('sailthru_key', models.CharField(help_text='API key for accessing Sailthru. ', max_length=32)),
('sailthru_secret', models.CharField(help_text='API secret for accessing Sailthru. ', max_length=32)),
('sailthru_new_user_list', models.CharField(help_text='Sailthru list name to add new users to. ', max_length=48)),
('sailthru_retry_interval', models.IntegerField(default=3600, help_text='Sailthru connection retry interval (secs).')),
('sailthru_max_retries', models.IntegerField(default=24, help_text='Sailthru maximum retries.')),
('sailthru_activation_template', models.CharField(help_text='Sailthru template to use on activation send. ', max_length=20)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
),
]
"""
Email-marketing-related models.
"""
from django.db import models
from django.utils.translation import ugettext_lazy as _
from config_models.models import ConfigurationModel
class EmailMarketingConfiguration(ConfigurationModel):
""" Email marketing configuration """
class Meta(object):
app_label = "email_marketing"
sailthru_key = models.fields.CharField(
max_length=32,
help_text=_(
"API key for accessing Sailthru. "
)
)
sailthru_secret = models.fields.CharField(
max_length=32,
help_text=_(
"API secret for accessing Sailthru. "
)
)
sailthru_new_user_list = models.fields.CharField(
max_length=48,
help_text=_(
"Sailthru list name to add new users to. "
)
)
sailthru_retry_interval = models.fields.IntegerField(
default=3600,
help_text=_(
"Sailthru connection retry interval (secs)."
)
)
sailthru_max_retries = models.fields.IntegerField(
default=24,
help_text=_(
"Sailthru maximum retries."
)
)
sailthru_activation_template = models.fields.CharField(
max_length=20,
help_text=_(
"Sailthru template to use on activation send. "
)
)
def __unicode__(self):
return u"Email marketing configuration: New user list %s, Activation template: %s" % \
(self.sailthru_new_user_list, self.sailthru_activation_template)
"""
This module contains signals needed for email integration
"""
import logging
import datetime
from django.dispatch import receiver
from student.models import UNENROLL_DONE
from student.cookies import CREATE_LOGON_COOKIE
from student.views import REGISTER_USER
from email_marketing.models import EmailMarketingConfiguration
from util.model_utils import USER_FIELD_CHANGED
from lms.djangoapps.email_marketing.tasks import update_user, update_user_email
from sailthru.sailthru_client import SailthruClient
from sailthru.sailthru_error import SailthruClientError
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']
@receiver(UNENROLL_DONE)
def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False,
**kwargs): # pylint: disable=unused-argument
"""
Signal receiver for unenrollments
"""
email_config = EmailMarketingConfiguration.current()
if not email_config.enabled:
return
# TBD
@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
try:
sailthru_client = SailthruClient(email_config.sailthru_key, email_config.sailthru_secret)
sailthru_response = \
sailthru_client.api_post("user", {'id': user.email, 'fields': {'keys': 1},
'vars': {'last_login_date':
datetime.datetime.now().strftime("%Y-%m-%d")}})
except SailthruClientError as exc:
log.error("Exception attempting to obtain cookie from Sailthru: %s", unicode(exc))
return response
if sailthru_response.is_ok():
if 'keys' in sailthru_response.json and 'cookie' in sailthru_response.json['keys']:
cookie = sailthru_response.json['keys']['cookie']
response.set_cookie(
'sailthru_hid',
cookie,
max_age=365 * 24 * 60 * 60 # set for 1 year
)
else:
log.error("No cookie returned attempting to obtain cookie from Sailthru for %s", user.email)
else:
error = sailthru_response.get_error()
log.error("Error attempting to obtain cookie from Sailthru: %s", error.get_message())
return response
@receiver(REGISTER_USER)
def email_marketing_register_user(sender, user=None, profile=None,
**kwargs): # pylint: disable=unused-argument
"""
Called after user created and saved
Args:
sender: Not used
user: The user object for the user being changed
profile: The user profile for the user being changed
kwargs: Not used
"""
log.info("Receiving REGISTER_USER")
email_config = EmailMarketingConfiguration.current()
if not email_config.enabled:
return
# ignore anonymous users
if user.is_anonymous():
return
# perform update asynchronously
update_user.delay(user.username, new_user=True)
@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 anything but User or Profile table
if table != 'auth_user' and table != 'auth_userprofile':
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
# perform update asynchronously, flag if activation
update_user.delay(user.username, new_user=False,
activation=(setting == 'is_active') and new_value is True)
elif setting == 'email':
# email update is special case
email_config = EmailMarketingConfiguration.current()
if not email_config.enabled:
return
update_user_email.delay(user.username, old_value)
""" email_marketing app. """
# this is here to support registering the signals in signals.py
from email_marketing import signals # pylint: disable=unused-import
"""
This file contains celery tasks for email marketing signal handler.
"""
import logging
import time
from celery import task
from django.contrib.auth.models import User
from email_marketing.models import EmailMarketingConfiguration
from sailthru.sailthru_client import SailthruClient
from sailthru.sailthru_error import SailthruClientError
log = logging.getLogger(__name__)
# pylint: disable=not-callable
@task(bind=True, default_retry_delay=3600, max_retries=24)
def update_user(self, username, new_user=False, activation=False):
"""
Adds/updates Sailthru profile information for a user.
Args:
username(str): A string representation of user identifier
Returns:
None
"""
email_config = EmailMarketingConfiguration.current()
if not email_config.enabled:
return
# get user
user = User.objects.select_related('profile').get(username=username)
if not user:
log.error("User not found during Sailthru update %s", username)
return
# get profile
profile = user.profile
if not profile:
log.error("User profile not found during Sailthru update %s", username)
return
sailthru_client = SailthruClient(email_config.sailthru_key, email_config.sailthru_secret)
try:
sailthru_response = sailthru_client.api_post("user",
_create_sailthru_user_parm(user, profile, new_user, email_config))
except SailthruClientError as exc:
log.error("Exception attempting to add/update user %s in Sailthru - %s", username, unicode(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()
# put out error and schedule retry
log.error("Error attempting to add/update user in Sailthru: %s", error.get_message())
raise self.retry(countdown=email_config.sailthru_retry_interval,
max_retries=email_config.sailthru_max_retries)
# if activating user, send welcome email
if activation and email_config.sailthru_activation_template:
try:
sailthru_response = sailthru_client.api_post("send",
{"email": user.email,
"template": email_config.sailthru_activation_template})
except SailthruClientError as exc:
log.error("Exception attempting to send welcome email to user %s in Sailthru - %s", username, unicode(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()
# probably an invalid template name, just put out error
log.error("Error attempting to send welcome email to user in Sailthru: %s", error.get_message())
# pylint: disable=not-callable
@task(bind=True, default_retry_delay=3600, max_retries=24)
def update_user_email(self, username, 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
# get user
user = User.objects.get(username=username)
if not user:
log.error("User not found duing Sailthru update %s", username)
return
# ignore if email not changed
if user.email == old_email:
return
sailthru_parms = {"id": old_email, "key": "email", "keysconflict": "merge", "keys": {"email": user.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", username, unicode(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())
raise self.retry(countdown=email_config.sailthru_retry_interval,
max_retries=email_config.sailthru_max_retries)
def _create_sailthru_user_parm(user, profile, new_user, email_config):
"""
Create sailthru user create/update parms from user + profile.
"""
sailthru_user = {'id': user.email, 'key': 'email'}
sailthru_vars = {'username': user.username,
'activated': int(user.is_active),
'joined_date': user.date_joined.strftime("%Y-%m-%d")}
sailthru_user['vars'] = sailthru_vars
sailthru_vars['last_changed_time'] = int(time.time())
if profile:
sailthru_vars['fullname'] = profile.name
sailthru_vars['gender'] = profile.gender
sailthru_vars['education'] = profile.level_of_education
# age is not useful since it is not automatically updated
#sailthru_vars['age'] = profile.age or -1
if profile.year_of_birth:
sailthru_vars['year_of_birth'] = profile.year_of_birth
sailthru_vars['country'] = unicode(profile.country.code)
# if new user add to list
if new_user and email_config.sailthru_new_user_list:
sailthru_user['lists'] = {email_config.sailthru_new_user_list: 1}
return sailthru_user
"""Tests of email marketing signal handlers."""
import logging
import ddt
from django.test import TestCase
from django.test.utils import override_settings
from mock import patch
from util.json_request import JsonResponse
from email_marketing.signals import handle_unenroll_done, \
email_marketing_register_user, \
email_marketing_user_field_changed, \
add_email_marketing_cookies
from email_marketing.tasks import update_user, update_user_email
from email_marketing.models import EmailMarketingConfiguration
from django.test.client import RequestFactory
from student.tests.factories import UserFactory, UserProfileFactory
from sailthru.sailthru_client import SailthruClient
from sailthru.sailthru_response import SailthruResponse
from sailthru.sailthru_error import SailthruClientError
log = logging.getLogger(__name__)
TEST_EMAIL = "test@edx.org"
def update_email_marketing_config(enabled=False, key='badkey', secret='badsecret', new_user_list='new list',
template='Activation'):
"""
Enable / Disable Sailthru integration
"""
EmailMarketingConfiguration.objects.create(
enabled=enabled,
sailthru_key=key,
sailthru_secret=secret,
sailthru_new_user_list=new_user_list,
sailthru_activation_template=template
)
@ddt.ddt
class EmailMarketingTests(TestCase):
"""
Tests for the EmailMarketing signals and tasks classes.
"""
def setUp(self):
self.request_factory = RequestFactory()
self.user = UserFactory.create(username='test', email=TEST_EMAIL)
self.profile = self.user.profile
self.request = self.request_factory.get("foo")
update_email_marketing_config(enabled=True)
super(EmailMarketingTests, self).setUp()
@patch('email_marketing.signals.SailthruClient.api_post')
def test_drop_cookie(self, mock_sailthru):
"""
Test add_email_marketing_cookies
"""
response = JsonResponse({
"success": True,
"redirect_url": 'test.com/test',
})
mock_sailthru.return_value = SailthruResponse(JsonResponse({'keys': {'cookie': 'test_cookie'}}))
add_email_marketing_cookies(None, response=response, user=self.user)
self.assertTrue('sailthru_hid' in response.cookies)
self.assertEquals(mock_sailthru.call_args[0][0], "user")
userparms = mock_sailthru.call_args[0][1]
self.assertEquals(userparms['fields']['keys'], 1)
self.assertEquals(userparms['id'], TEST_EMAIL)
self.assertEquals(response.cookies['sailthru_hid'].value, "test_cookie")
@patch('email_marketing.signals.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)
self.assertFalse('sailthru_hid' in response.cookies)
mock_sailthru.return_value = SailthruResponse(JsonResponse({'error': "error", "errormsg": "errormsg"}))
add_email_marketing_cookies(None, response=response, user=self.user)
self.assertFalse('sailthru_hid' in response.cookies)
mock_sailthru.side_effect = SailthruClientError
add_email_marketing_cookies(None, response=response, user=self.user)
self.assertFalse('sailthru_hid' in response.cookies)
@patch('email_marketing.tasks.log.error')
@patch('email_marketing.tasks.SailthruClient.api_post')
def test_add_user(self, mock_sailthru, mock_log_error):
"""
test async method in tasks that actually updates Sailthru
"""
mock_sailthru.return_value = SailthruResponse(JsonResponse({'ok': True}))
update_user.delay(self.user.username, new_user=True)
self.assertFalse(mock_log_error.called)
self.assertEquals(mock_sailthru.call_args[0][0], "user")
userparms = mock_sailthru.call_args[0][1]
self.assertEquals(userparms['key'], "email")
self.assertEquals(userparms['id'], TEST_EMAIL)
self.assertEquals(userparms['vars']['gender'], "m")
self.assertEquals(userparms['vars']['username'], "test")
self.assertEquals(userparms['vars']['activated'], 1)
self.assertEquals(userparms['lists']['new list'], 1)
@patch('email_marketing.tasks.SailthruClient.api_post')
def test_activation(self, mock_sailthru):
"""
test send of activation template
"""
mock_sailthru.return_value = SailthruResponse(JsonResponse({'ok': True}))
update_user.delay(self.user.username, new_user=True, activation=True)
# look for call args for 2nd call
self.assertEquals(mock_sailthru.call_args[0][0], "send")
userparms = mock_sailthru.call_args[0][1]
self.assertEquals(userparms['email'], TEST_EMAIL)
self.assertEquals(userparms['template'], "Activation")
@patch('email_marketing.tasks.log.error')
@patch('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.delay(self.user.username)
self.assertTrue(mock_log_error.called)
@patch('email_marketing.tasks.log.error')
@patch('email_marketing.tasks.SailthruClient.api_post')
def test_just_return(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.assertFalse(mock_log_error.called)
self.assertFalse(mock_sailthru.called)
update_user_email.delay(self.user.username, "newemail2@test.com")
self.assertFalse(mock_log_error.called)
self.assertFalse(mock_sailthru.called)
update_email_marketing_config(enabled=True)
@patch('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}))
#self.user.email = "newemail@test.com"
update_user_email.delay(self.user.username, "old@edx.org")
self.assertEquals(mock_sailthru.call_args[0][0], "user")
userparms = mock_sailthru.call_args[0][1]
self.assertEquals(userparms['key'], "email")
self.assertEquals(userparms['id'], "old@edx.org")
self.assertEquals(userparms['keys']['email'], TEST_EMAIL)
@patch('email_marketing.tasks.log.error')
@patch('email_marketing.tasks.SailthruClient.api_post')
def test_error_logging1(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")
self.assertTrue(mock_log_error.called)
@patch('lms.djangoapps.email_marketing.tasks.update_user.delay')
def test_register_user(self, mock_update_user):
"""
make sure register user call invokes update_user
"""
email_marketing_register_user(None, user=self.user, profile=self.profile)
self.assertTrue(mock_update_user.called)
@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))
@ddt.unpack
def test_modify_field(self, table, setting, value, result, mock_update_user):
"""
Test that correct fields call update_user
"""
email_marketing_user_field_changed(None, self.user, table=table, setting=setting, new_value=value)
self.assertEqual(mock_update_user.called, result)
......@@ -2070,6 +2070,9 @@ INSTALLED_APPS = (
# Enables default site and redirects
'django_sites_extensions',
# Email marketing integration
'email_marketing',
)
# Migrations which are not in the standard module "migrations"
......
......@@ -180,3 +180,6 @@ jsonfield==1.0.3
# Inlines CSS styles into HTML for email notifications.
pynliner==0.5.2
# for sailthru integration
sailthru-client==2.2.3
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment