Unverified Commit 1ebf9cf8 by Uzair Rasheed Committed by GitHub

Merge pull request #16229 from edx/celery-task

celery task to update sailthru purchase record
parents d4af6ec0 1b6ed3ba
......@@ -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