Commit ca02235e by PaulWattenberger

Pwattenberger/sailthru (#12646)

* Initial commit of Sailthru lms changes

* Field mapping changes for Sailthru integration

* Merge fix

* Add users to Sailthru list on registration

* FIx minor code format issues

* Several updates based on code review feedback

* Updates based on recomendations from Sailthru

* Clean up unit tests

* Fix last login

* Updates based on code review feedback

* Updates based on code review feedback

* Fix comment

* Cleanup
parent e275dfbc
......@@ -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='Sailthru api key.', max_length=32, null=True, blank=True)),
('sailthru_secret',
models.CharField(help_text='Sailthru secret.', max_length=32, null=True, blank=True)),
('sailthru_new_user_list',
models.CharField(help_text='Sailthru new user list.', max_length=48, null=True, blank=True)),
('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 activation template.', max_length=20, null=True, blank=True)),
('changed_by',
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
),
]
\ No newline at end of file
"""
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,
blank=True,
help_text=_(
"Sailthru template to use on activation send. "
)
)
"""
This module contains signals needed for email integration
"""
import logging
import datetime
from django.dispatch import receiver
from student.models import CourseEnrollment, 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
"""
email_config = EmailMarketingConfiguration.current()
if not email_config.enabled:
return
# 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:
# 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
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
"""
This file contains celery tasks for email marketing signal handler.
"""
import logging
import time
from pytz import UTC
from celery import task
from django.contrib.auth.models import User
from student.models import UserProfile
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
# ignore anonymous users
if user.is_anonymous():
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 anonymous users
if user.is_anonymous():
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.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
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