Commit 1b6ed3ba by uzairr

Celery task to update sailthru purchase record

After change in the audit enrollment process, edX platform is
no longer keeping its audit enrollment record on sailthru.To keep
updated sailthru a celery task is created that will update sailthru
user profile in case of enroll/un-enroll of any audit enrollment course.

LEARNER-2694
parent 51f648ed
......@@ -7,16 +7,19 @@ import logging
import crum
from django.conf import settings
from django.dispatch import receiver
from sailthru.sailthru_client import SailthruClient
from sailthru.sailthru_error import SailthruClientError
from celery.exceptions import TimeoutError
from course_modes.models import CourseMode
from email_marketing.models import EmailMarketingConfiguration
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from lms.djangoapps.email_marketing.tasks import update_user, update_user_email, get_email_cookies_via_sailthru
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from student.cookies import CREATE_LOGON_COOKIE
from student.signals import ENROLL_STATUS_CHANGE
from student.views import REGISTER_USER
from util.model_utils import USER_FIELD_CHANGED
from .tasks import update_course_enrollment
log = logging.getLogger(__name__)
......@@ -25,6 +28,28 @@ CHANGED_FIELDNAMES = ['username', 'is_active', 'name', 'gender', 'education',
'age', 'level_of_education', 'year_of_birth',
'country', LANGUAGE_KEY]
WAFFLE_NAMESPACE = 'sailthru'
WAFFLE_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE)
SAILTHRU_AUDIT_PURCHASE_ENABLED = 'audit_purchase_enabled'
@receiver(ENROLL_STATUS_CHANGE)
def update_sailthru(sender, event, user, mode, course_id, **kwargs):
"""
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:
course_key = str(course_id)
email = str(user.email)
update_course_enrollment.delay(email, course_key, mode)
@receiver(CREATE_LOGON_COOKIE)
def add_email_marketing_cookies(sender, response=None, user=None,
......
......@@ -291,3 +291,191 @@ def _retryable_sailthru_error(error):
"""
code = error.get_error_code()
return code == 9 or code == 43
@task(bind=True)
def update_course_enrollment(self, email, course_key, mode):
"""Adds/updates Sailthru when a user adds to cart/purchases/upgrades a course
Args:
user: current user
course_key: course key of course
Returns:
None
"""
course_url = build_course_url(course_key)
config = EmailMarketingConfiguration.current()
try:
sailthru_client = SailthruClient(config.sailthru_key, config.sailthru_secret)
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, None)
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=unicode(course_key))
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, unicode(exc))
return False
def schedule_retry(self, config):
"""Schedule a retry"""
raise self.retry(countdown=config.sailthru_retry_interval,
max_retries=config.sailthru_max_retries)
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 = "{}:{}".format(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
def _build_purchase_item(course_id, course_url, cost_in_cents, mode, course_data, sku):
"""Build and return Sailthru purchase item object"""
# build item description
item = {
'id': "{}-{}".format(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'] = 'Course {} mode: {}'.format(course_id, mode)
if 'tags' in course_data:
item['tags'] = course_data['tags']
return item
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, unicode(exc))
return False
return True
......@@ -19,7 +19,8 @@ from email_marketing.models import EmailMarketingConfiguration
from email_marketing.signals import (
add_email_marketing_cookies,
email_marketing_register_user,
email_marketing_user_field_changed
email_marketing_user_field_changed,
update_sailthru
)
from email_marketing.tasks import (
_create_user_list,
......@@ -27,11 +28,12 @@ from email_marketing.tasks import (
_get_or_create_user_list,
update_user,
update_user_email,
get_email_cookies_via_sailthru
get_email_cookies_via_sailthru,
update_course_enrollment,
)
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from student.models import Registration
from student.tests.factories import UserFactory, UserProfileFactory
from student.tests.factories import UserFactory, UserProfileFactory, CourseEnrollmentFactory
from util.json_request import JsonResponse
log = logging.getLogger(__name__)
......@@ -89,7 +91,7 @@ class EmailMarketingTests(TestCase):
@freeze_time(datetime.datetime.now())
@patch('email_marketing.signals.crum.get_current_request')
@patch('email_marketing.signals.SailthruClient.api_post')
@patch('sailthru.sailthru_client.SailthruClient.api_post')
def test_drop_cookie(self, mock_sailthru, mock_get_current_request):
"""
Test add_email_marketing_cookies
......@@ -127,7 +129,7 @@ class EmailMarketingTests(TestCase):
self.assertTrue('sailthru_hid' in response.cookies)
self.assertEquals(response.cookies['sailthru_hid'].value, "test_cookie")
@patch('email_marketing.signals.SailthruClient.api_post')
@patch('sailthru.sailthru_client.SailthruClient.api_post')
def test_get_cookies_via_sailthu(self, mock_sailthru):
cookies = {'cookie': 'test_cookie'}
......@@ -149,7 +151,7 @@ class EmailMarketingTests(TestCase):
self.assertEqual(cookies['cookie'], expected_cookie.result)
@patch('email_marketing.signals.SailthruClient.api_post')
@patch('sailthru.sailthru_client.SailthruClient.api_post')
def test_drop_cookie_error_path(self, mock_sailthru):
"""
test that error paths return no cookie
......@@ -523,3 +525,103 @@ class EmailMarketingTests(TestCase):
update_email_marketing_config(enabled=False)
email_marketing_user_field_changed(None, self.user, table='auth_user', setting='email', old_value='new@a.com')
self.assertFalse(mock_update_user.called)
class MockSailthruResponse(object):
"""
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(object):
"""
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(SailthruTests, self).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')
def test_update_course_enrollment(self, mock_sailthru_api_post,
mock_sailthru_api_get, mock_sailthru_purchase):
"""test update sailthru user record"""
# create mocked Sailthru API responses
mock_sailthru_api_post.return_value = MockSailthruResponse({'ok': True})
mock_sailthru_api_get.return_value = MockSailthruResponse({'user': {"id": TEST_EMAIL, "fields": {"vars": 1}}})
mock_sailthru_purchase.return_value = MockSailthruResponse({'ok': True})
self.user.email = TEST_EMAIL
CourseEnrollmentFactory(user=self.user, course_id=self.course_id)
with patch('email_marketing.tasks.build_course_url') as m:
m.return_value = self.course_url
update_course_enrollment(TEST_EMAIL, self.course_id, 'audit')
item = [{
'url': self.course_url,
'price': 0,
'qty': 1,
'id': 'edX/toy/2012_Fall-audit',
'title': 'Course edX/toy/2012_Fall mode: audit'
}]
mock_sailthru_purchase.assert_called_with(TEST_EMAIL, item, options={})
@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, None, self.user, 'verified', self.course_id)
self.assertFalse(mock_sailthru_purchase.called)
@patch('openedx.core.djangoapps.waffle_utils.WaffleSwitchNamespace.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, None, self.user, 'verified', self.course_id)
self.assertFalse(mock_sailthru_purchase.called)
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