diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 6e6c24446b80d2d9d98bfc64b8381fe61e2bf7a5..45bb422c6c1ecc1a2c9a501bce78dc852518f0a7 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -61,10 +61,32 @@ from util.milestones_helpers import is_entrance_exams_enabled UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"]) +ENROLL_STATUS_CHANGE = Signal(providing_args=["event", "user", "course_id", "mode", "cost", "currency"]) 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(object): + """ + Possible event types for ENROLL_STATUS_CHANGE signal + """ + # enroll for a course + enroll = 'enroll' + # unenroll for a course + unenroll = 'unenroll' + # add an upgrade to cart + upgrade_start = 'upgrade_start' + # complete an upgrade purchase + upgrade_complete = 'upgrade_complete' + # add a paid course to the cart + paid_start = 'paid_start' + # complete a paid course purchase + paid_complete = 'paid_complete' + UNENROLLED_TO_ALLOWEDTOENROLL = 'from unenrolled to allowed to enroll' ALLOWEDTOENROLL_TO_ENROLLED = 'from allowed to enroll to enrolled' ENROLLED_TO_ENROLLED = 'from enrolled to enrolled' @@ -1113,6 +1135,7 @@ class CourseEnrollment(models.Model): UNENROLL_DONE.send(sender=None, course_enrollment=self, skip_refund=skip_refund) self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED) + self.send_signal(EnrollStatusChange.unenroll) dog_stats_api.increment( "common.student.unenrollment", @@ -1125,6 +1148,24 @@ class CourseEnrollment(models.Model): # mode has changed from its previous setting self.emit_event(EVENT_NAME_ENROLLMENT_MODE_CHANGED) + def send_signal(self, event, cost=None, currency=None): + """ + Sends a signal announcing changes in course enrollment status. + """ + ENROLL_STATUS_CHANGE.send(sender=None, event=event, user=self.user, + mode=self.mode, course_id=self.course_id, + cost=cost, currency=currency) + + @classmethod + def send_signal_full(cls, event, user=user, mode=mode, course_id=course_id, cost=None, currency=None): + """ + Sends a signal announcing changes in course enrollment status. + This version should be used if you don't already have a CourseEnrollment object + """ + ENROLL_STATUS_CHANGE.send(sender=None, event=event, user=user, + mode=mode, course_id=course_id, + cost=cost, currency=currency) + def emit_event(self, event_name): """ Emits an event to explicitly track course enrollment and unenrollment. diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index d94113f9e7ae43c4961559dd7461ff1074d1776e..186203f95de5cd7d5617232c2282bda2611823f0 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -112,7 +112,7 @@ from student.helpers import ( DISABLE_UNENROLL_CERT_STATES, ) from student.cookies import set_logged_in_cookies, delete_logged_in_cookies -from student.models import anonymous_id_for_user, UserAttribute +from student.models import anonymous_id_for_user, UserAttribute, EnrollStatusChange from shoppingcart.models import DonationConfiguration, CourseRegistrationCode from embargo import api as embargo_api @@ -1065,7 +1065,8 @@ def change_enrollment(request, check_access=True): try: enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes) if enroll_mode: - CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) + enrollment = CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) + enrollment.send_signal(EnrollStatusChange.enroll) except Exception: # pylint: disable=broad-except return HttpResponseBadRequest(_("Could not enroll")) diff --git a/lms/djangoapps/email_marketing/migrations/0002_auto_20160623_1656.py b/lms/djangoapps/email_marketing/migrations/0002_auto_20160623_1656.py new file mode 100644 index 0000000000000000000000000000000000000000..4761335a6cbb5c00c4caa6dade7d30d8d9b75ce5 --- /dev/null +++ b/lms/djangoapps/email_marketing/migrations/0002_auto_20160623_1656.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('email_marketing', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='emailmarketingconfiguration', + name='sailthru_abandoned_cart_delay', + field=models.IntegerField(default=60, help_text='Sailthru minutes to wait before sending abandoned cart message.'), + ), + migrations.AddField( + model_name='emailmarketingconfiguration', + name='sailthru_abandoned_cart_template', + field=models.CharField(help_text='Sailthru template to use on abandoned cart reminder. ', max_length=20, blank=True), + ), + migrations.AddField( + model_name='emailmarketingconfiguration', + name='sailthru_content_cache_age', + field=models.IntegerField(default=3600, help_text='Number of seconds to cache course content retrieved from Sailthru.'), + ), + migrations.AddField( + model_name='emailmarketingconfiguration', + name='sailthru_enroll_cost', + field=models.IntegerField(default=100, help_text='Cost in cents to report to Sailthru for enrolls.'), + ), + migrations.AddField( + model_name='emailmarketingconfiguration', + name='sailthru_enroll_template', + field=models.CharField(help_text='Sailthru send template to use on enrolling for audit. ', max_length=20, blank=True), + ), + migrations.AddField( + model_name='emailmarketingconfiguration', + name='sailthru_get_tags_from_sailthru', + field=models.BooleanField(default=True, help_text='Use the Sailthru content API to fetch course tags.'), + ), + migrations.AddField( + model_name='emailmarketingconfiguration', + name='sailthru_purchase_template', + field=models.CharField(help_text='Sailthru send template to use on purchasing a course seat. ', max_length=20, blank=True), + ), + migrations.AddField( + model_name='emailmarketingconfiguration', + name='sailthru_upgrade_template', + field=models.CharField(help_text='Sailthru send template to use on upgrading a course. ', max_length=20, blank=True), + ), + migrations.AlterField( + model_name='emailmarketingconfiguration', + name='sailthru_activation_template', + field=models.CharField(help_text='Sailthru template to use on activation send. ', max_length=20, blank=True), + ), + ] diff --git a/lms/djangoapps/email_marketing/models.py b/lms/djangoapps/email_marketing/models.py index 72f517b38e7303469c0d5c7e9327242dbfeb2c9f..d31b71f73bc9f985ffc2c7405df39f1e0b44cf3e 100644 --- a/lms/djangoapps/email_marketing/models.py +++ b/lms/djangoapps/email_marketing/models.py @@ -50,11 +50,75 @@ class EmailMarketingConfiguration(ConfigurationModel): sailthru_activation_template = models.fields.CharField( max_length=20, + blank=True, help_text=_( "Sailthru template to use on activation send. " ) ) + sailthru_abandoned_cart_template = models.fields.CharField( + max_length=20, + blank=True, + help_text=_( + "Sailthru template to use on abandoned cart reminder. " + ) + ) + + sailthru_abandoned_cart_delay = models.fields.IntegerField( + default=60, + help_text=_( + "Sailthru minutes to wait before sending abandoned cart message." + ) + ) + + sailthru_enroll_template = models.fields.CharField( + max_length=20, + blank=True, + help_text=_( + "Sailthru send template to use on enrolling for audit. " + ) + ) + + sailthru_upgrade_template = models.fields.CharField( + max_length=20, + blank=True, + help_text=_( + "Sailthru send template to use on upgrading a course. " + ) + ) + + sailthru_purchase_template = models.fields.CharField( + max_length=20, + blank=True, + help_text=_( + "Sailthru send template to use on purchasing a course seat. " + ) + ) + + # Sailthru purchases can be tagged with interest tags to provide information about the types of courses + # users are interested in. The easiest way to get the tags currently is the Sailthru content API which + # looks in the content library (the content library is populated daily with a script that pulls the data + # from the course discovery API) This option should normally be on, but it does add overhead to processing + # purchases and enrolls. + sailthru_get_tags_from_sailthru = models.BooleanField( + default=True, + help_text=_('Use the Sailthru content API to fetch course tags.') + ) + + sailthru_content_cache_age = models.fields.IntegerField( + default=3600, + help_text=_( + "Number of seconds to cache course content retrieved from Sailthru." + ) + ) + + sailthru_enroll_cost = models.fields.IntegerField( + default=100, + help_text=_( + "Cost in cents to report to Sailthru for enrolls." + ) + ) + def __unicode__(self): return u"Email marketing configuration: New user list %s, Activation template: %s" % \ (self.sailthru_new_user_list, self.sailthru_activation_template) diff --git a/lms/djangoapps/email_marketing/signals.py b/lms/djangoapps/email_marketing/signals.py index e5504fc62335cce974399fb59d819ce84deb607e..b5979ddec0326c8f4bb25c938e1cdca6347411c7 100644 --- a/lms/djangoapps/email_marketing/signals.py +++ b/lms/djangoapps/email_marketing/signals.py @@ -3,15 +3,16 @@ This module contains signals needed for email integration """ import logging import datetime +import crum from django.dispatch import receiver -from student.models import UNENROLL_DONE +from student.models import ENROLL_STATUS_CHANGE 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 lms.djangoapps.email_marketing.tasks import update_user, update_user_email, update_course_enrollment from sailthru.sailthru_client import SailthruClient from sailthru.sailthru_error import SailthruClientError @@ -24,17 +25,45 @@ CHANGED_FIELDNAMES = ['username', 'is_active', 'name', 'gender', 'education', 'country'] -@receiver(UNENROLL_DONE) -def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False, - **kwargs): # pylint: disable=unused-argument +@receiver(ENROLL_STATUS_CHANGE) +def handle_enroll_status_change(sender, event=None, user=None, mode=None, course_id=None, cost=None, currency=None, + **kwargs): # pylint: disable=unused-argument """ - Signal receiver for unenrollments + Signal receiver for enroll/unenroll/purchase events """ email_config = EmailMarketingConfiguration.current() - if not email_config.enabled: + if not email_config.enabled or not event or not user or not mode or not course_id: + return + + request = crum.get_current_request() + if not request: return - # TBD + # figure out course url + course_url = _build_course_url(request, course_id.to_deprecated_string()) + + # pass event to email_marketing.tasks + update_course_enrollment.delay(user.email, course_url, event, mode, + unit_cost=cost, course_id=course_id, currency=currency, + message_id=request.COOKIES.get('sailthru_bid')) + + +def _build_course_url(request, course_id): + """ + Build a course url from a course id and the host from the current request + :param request: + :param course_id: + :return: + """ + host = request.get_host() + # hack for integration testing since Sailthru rejects urls with localhost + if host.startswith('localhost'): + host = 'courses.edx.org' + return '{scheme}://{host}/courses/{course}/info'.format( + scheme=request.scheme, + host=host, + course=course_id + ) @receiver(CREATE_LOGON_COOKIE) @@ -54,12 +83,23 @@ def add_email_marketing_cookies(sender, response=None, user=None, 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 sailthru_content cookie to capture usage before logon + request = crum.get_current_request() + if request: + sailthru_content = request.COOKIES.get('sailthru_content') + if sailthru_content: + post_parms['cookies'] = {'sailthru_content': sailthru_content} + 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")}}) + sailthru_client.api_post("user", post_parms) except SailthruClientError as exc: log.error("Exception attempting to obtain cookie from Sailthru: %s", unicode(exc)) return response @@ -93,7 +133,6 @@ def email_marketing_register_user(sender, user=None, profile=None, 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 diff --git a/lms/djangoapps/email_marketing/tasks.py b/lms/djangoapps/email_marketing/tasks.py index b7bae0d5463874ff60a02ebecee3cc6f0e654bb0..4c9c5723c9f3acacef83a4099e6a707b60187eea 100644 --- a/lms/djangoapps/email_marketing/tasks.py +++ b/lms/djangoapps/email_marketing/tasks.py @@ -6,8 +6,13 @@ import time from celery import task from django.contrib.auth.models import User +from django.http import Http404 +from django.core.cache import cache from email_marketing.models import EmailMarketingConfiguration +from course_modes.models import CourseMode +from courseware.courses import get_course_by_id +from student.models import EnrollStatusChange from sailthru.sailthru_client import SailthruClient from sailthru.sailthru_error import SailthruClientError @@ -30,16 +35,14 @@ def update_user(self, username, new_user=False, activation=False): return # get user - user = User.objects.select_related('profile').get(username=username) - if not user: + try: + user = User.objects.select_related('profile').get(username=username) + except User.DoesNotExist: 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: @@ -92,8 +95,9 @@ def update_user_email(self, username, old_email): return # get user - user = User.objects.get(username=username) - if not user: + try: + user = User.objects.get(username=username) + except User.DoesNotExist: log.error("User not found duing Sailthru update %s", username) return @@ -145,3 +149,269 @@ def _create_sailthru_user_parm(user, profile, new_user, email_config): sailthru_user['lists'] = {email_config.sailthru_new_user_list: 1} return sailthru_user + + +# pylint: disable=not-callable +@task(bind=True, default_retry_delay=3600, max_retries=24) +def update_course_enrollment(self, email, course_url, event, mode, + unit_cost=None, course_id=None, + currency=None, message_id=None): # pylint: disable=unused-argument + """ + Adds/updates Sailthru when a user enrolls/unenrolls/adds to cart/purchases/upgrades a course + Args: + email(str): The user's email address + course_url(str): Course home page url + event(str): event type + mode(object): enroll mode (audit, verification, ...) + unit_cost: cost if purchase event + course_id(CourseKey): course id + currency(str): currency if purchase event - currently ignored since Sailthru only supports USD + Returns: + None + + + The event can be one of the following: + EnrollStatusChange.enroll + A free enroll (mode=audit) + EnrollStatusChange.unenroll + An unenroll + EnrollStatusChange.upgrade_start + A paid upgrade added to cart + EnrollStatusChange.upgrade_complete + A paid upgrade purchase complete + EnrollStatusChange.paid_start + A non-free course added to cart + EnrollStatusChange.paid_complete + A non-free course purchase complete + """ + + email_config = EmailMarketingConfiguration.current() + if not email_config.enabled: + return + + course_id_string = course_id.to_deprecated_string() + + # Use event type to figure out processing required + new_enroll = unenroll = fetch_tags = False + incomplete = send_template = None + if unit_cost: + cost_in_cents = unit_cost * 100 + + if event == EnrollStatusChange.enroll: + # new enroll for audit (no cost) + new_enroll = True + fetch_tags = True + send_template = email_config.sailthru_enroll_template + # set cost of $1 so that Sailthru recognizes the event + cost_in_cents = email_config.sailthru_enroll_cost + + elif event == EnrollStatusChange.unenroll: + # unenroll - need to update list of unenrolled courses for user in Sailthru + unenroll = True + + elif event == EnrollStatusChange.upgrade_start: + # add upgrade to cart + incomplete = 1 + + elif event == EnrollStatusChange.paid_start: + # add course purchase (probably 'honor') to cart + incomplete = 1 + + elif event == EnrollStatusChange.upgrade_complete: + # upgrade complete + fetch_tags = True + send_template = email_config.sailthru_upgrade_template + + elif event == EnrollStatusChange.paid_complete: + # paid course purchase complete + new_enroll = True + fetch_tags = True + send_template = email_config.sailthru_purchase_template + + sailthru_client = SailthruClient(email_config.sailthru_key, email_config.sailthru_secret) + + # update the "unenrolled" course array in the user record on Sailthru if new enroll or unenroll + if new_enroll or unenroll: + if not _update_unenrolled_list(sailthru_client, email, course_url, unenroll): + raise self.retry(countdown=email_config.sailthru_retry_interval, + max_retries=email_config.sailthru_max_retries) + + # if there is a cost, call Sailthru purchase api to record + if cost_in_cents: + + # get course information if configured and appropriate event + if fetch_tags and email_config.sailthru_get_tags_from_sailthru: + course_data = _get_course_content(course_url, sailthru_client, email_config) + else: + course_data = {} + + # build item description + item = _build_purchase_item(course_id_string, course_url, cost_in_cents, mode, course_data, course_id) + + # build purchase api options list + options = {} + if incomplete and email_config.sailthru_abandoned_cart_template: + options['reminder_template'] = email_config.sailthru_abandoned_cart_template + options['reminder_time'] = "+{} minutes".format(email_config.sailthru_abandoned_cart_delay) + + # add appropriate send template + if send_template: + options['send_template'] = send_template + + if not _record_purchase(sailthru_client, email, item, incomplete, message_id, options): + raise self.retry(countdown=email_config.sailthru_retry_interval, + max_retries=email_config.sailthru_max_retries) + + +def _build_purchase_item(course_id_string, course_url, cost_in_cents, mode, course_data, course_id): + """ + Build Sailthru purchase item object + :return: item + """ + + # build item description + item = { + 'id': "{}-{}".format(course_id_string, 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: + try: + course = get_course_by_id(course_id) + item['title'] = course.display_name + except Http404: + # can't find, just invent title + item['title'] = 'Course {} mode: {}'.format(course_id_string, mode) + + if 'tags' in course_data: + item['tags'] = course_data['tags'] + + # add vars to item + sailthru_vars = {} + if 'vars' in course_data: + sailthru_vars = course_data['vars'] + sailthru_vars['mode'] = mode + sailthru_vars['course_run_id'] = course_id_string + item['vars'] = sailthru_vars + + # get list of modes for course and add upgrade deadlines for verified modes + for mode_entry in CourseMode.modes_for_course(course_id): + if mode_entry.expiration_datetime is not None and CourseMode.is_verified_slug(mode_entry.slug): + sailthru_vars['upgrade_deadline_{}'.format(mode_entry.slug)] = \ + mode_entry.expiration_datetime.strftime("%Y-%m-%d") + + return item + + +def _record_purchase(sailthru_client, email, item, incomplete, message_id, options): + """ + Record a purchase in Sailthru + :param sailthru_client: + :param email: + :param item: + :param incomplete: + :param message_id: + :param options: + :return: False it retryable error + """ + try: + sailthru_response = sailthru_client.purchase(email, [item], + incomplete=incomplete, message_id=message_id, + 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 False + + except SailthruClientError as exc: + log.error("Exception attempting to record purchase for %s in Sailthru - %s", email, unicode(exc)) + return False + + return True + + +def _get_course_content(course_url, sailthru_client, email_config): + """ + Get course information using the Sailthru content api. + + If there is an error, just return with an empty response. + :param course_url: + :param sailthru_client: + :return: dict with course information + """ + # check cache first + response = cache.get(course_url) + if not response: + try: + sailthru_response = sailthru_client.api_get("content", {"id": course_url}) + + if not sailthru_response.is_ok(): + return {} + + response = sailthru_response.json + cache.set(course_url, response, email_config.sailthru_content_cache_age) + + except SailthruClientError: + response = {} + + return response + + +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 + :param sailthru_client: + :param email: + :param email_config: + :param course_url: + :param unenroll: + :return: 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 False + + response_json = sailthru_response.json + + unenroll_list = [] + if response_json and "vars" in response_json 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 False + + # everything worked + return True + + except SailthruClientError as exc: + log.error("Exception attempting to update user record for %s in Sailthru - %s", email, unicode(exc)) + return False diff --git a/lms/djangoapps/email_marketing/tests/test_signals.py b/lms/djangoapps/email_marketing/tests/test_signals.py index c39f7700be842cba6fcfe873c921701063374486..3adef8d7a3993d1f28477a2b5a38cbfc98b8b8b9 100644 --- a/lms/djangoapps/email_marketing/tests/test_signals.py +++ b/lms/djangoapps/email_marketing/tests/test_signals.py @@ -1,20 +1,28 @@ """Tests of email marketing signal handlers.""" import logging import ddt +import datetime from django.test import TestCase -from django.test.utils import override_settings -from mock import patch +from django.contrib.auth.models import AnonymousUser +from mock import patch, ANY from util.json_request import JsonResponse +from django.http import Http404 -from email_marketing.signals import handle_unenroll_done, \ +from email_marketing.signals import handle_enroll_status_change, \ 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.tasks import update_user, update_user_email, update_course_enrollment, \ + _get_course_content, _update_unenrolled_list from email_marketing.models import EmailMarketingConfiguration from django.test.client import RequestFactory from student.tests.factories import UserFactory, UserProfileFactory +from request_cache.middleware import RequestCache +from student.models import EnrollStatusChange +from opaque_keys.edx.keys import CourseKey +from course_modes.models import CourseMode +from xmodule.modulestore.tests.factories import CourseFactory from sailthru.sailthru_client import SailthruClient from sailthru.sailthru_response import SailthruResponse @@ -35,7 +43,12 @@ def update_email_marketing_config(enabled=False, key='badkey', secret='badsecret sailthru_key=key, sailthru_secret=secret, sailthru_new_user_list=new_user_list, - sailthru_activation_template=template + sailthru_activation_template=template, + sailthru_enroll_template='enroll_template', + sailthru_upgrade_template='upgrade_template', + sailthru_purchase_template='purchase_template', + sailthru_abandoned_cart_template='abandoned_template', + sailthru_get_tags_from_sailthru=False ) @@ -51,10 +64,16 @@ class EmailMarketingTests(TestCase): self.profile = self.user.profile 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' super(EmailMarketingTests, self).setUp() + @patch('email_marketing.signals.crum.get_current_request') @patch('email_marketing.signals.SailthruClient.api_post') - def test_drop_cookie(self, mock_sailthru): + def test_drop_cookie(self, mock_sailthru, mock_get_current_request): """ Test add_email_marketing_cookies """ @@ -62,13 +81,16 @@ class EmailMarketingTests(TestCase): "success": True, "redirect_url": 'test.com/test', }) + self.request.COOKIES['sailthru_content'] = 'cookie_content' + mock_get_current_request.return_value = self.request mock_sailthru.return_value = SailthruResponse(JsonResponse({'keys': {'cookie': 'test_cookie'}})) add_email_marketing_cookies(None, response=response, user=self.user) + mock_sailthru.assert_called_with('user', + {'fields': {'keys': 1}, + 'cookies': {'sailthru_content': 'cookie_content'}, + 'id': TEST_EMAIL, + 'vars': {'last_login_date': ANY}}) 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') @@ -111,7 +133,7 @@ class EmailMarketingTests(TestCase): self.assertEquals(userparms['lists']['new list'], 1) @patch('email_marketing.tasks.SailthruClient.api_post') - def test_activation(self, mock_sailthru): + def test_user_activation(self, mock_sailthru): """ test send of activation template """ @@ -125,7 +147,7 @@ class EmailMarketingTests(TestCase): @patch('email_marketing.tasks.log.error') @patch('email_marketing.tasks.SailthruClient.api_post') - def test_error_logging(self, mock_sailthru, mock_log_error): + def test_update_user_error_logging(self, mock_sailthru, mock_log_error): """ Ensure that error returned from Sailthru api is logged """ @@ -133,28 +155,121 @@ class EmailMarketingTests(TestCase): update_user.delay(self.user.username) self.assertTrue(mock_log_error.called) + # force Sailthru API exception + mock_sailthru.side_effect = SailthruClientError + update_user.delay(self.user.username) + self.assertTrue(mock_log_error.called) + + # force Sailthru API exception on 2nd call + mock_sailthru.side_effect = [None, SailthruClientError] + mock_sailthru.return_value = SailthruResponse(JsonResponse({'ok': True})) + update_user.delay(self.user.username, new_user=True) + self.assertTrue(mock_log_error.called) + + # force Sailthru API error return on 2nd call + mock_sailthru.side_effect = None + mock_sailthru.return_value = [SailthruResponse(JsonResponse({'ok': True})), + SailthruResponse(JsonResponse({'error': 100, 'errormsg': 'Got an error'}))] + update_user.delay(self.user.username, new_user=True) + 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): + def test_update_user_error_logging_bad_user(self, mock_sailthru, mock_log_error): + """ + Test update_user with invalid user + """ + update_user.delay('baduser') + self.assertTrue(mock_log_error.called) + self.assertFalse(mock_sailthru.called) + + update_user_email.delay('baduser', 'aa@bb.com') + self.assertTrue(mock_log_error.called) + self.assertFalse(mock_sailthru.called) + + @patch('email_marketing.tasks.log.error') + @patch('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.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_course_enrollment.delay(self.user.username, TEST_EMAIL, 'http://course', + EnrollStatusChange.enroll, 'audit') + self.assertFalse(mock_log_error.called) + self.assertFalse(mock_sailthru.called) + + update_email_marketing_config(enabled=True) + + @patch('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) + + handle_enroll_status_change(None) + self.assertFalse(mock_log_error.called) + + add_email_marketing_cookies(None) + self.assertFalse(mock_log_error.called) + + email_marketing_register_user(None) + self.assertFalse(mock_log_error.called) + update_email_marketing_config(enabled=True) + # test anonymous users + anon = AnonymousUser() + email_marketing_register_user(None, user=anon) + self.assertFalse(mock_log_error.called) + + email_marketing_user_field_changed(None, user=anon) + self.assertFalse(mock_log_error.called) + + @patch('email_marketing.signals.crum.get_current_request') + @patch('lms.djangoapps.email_marketing.tasks.update_course_enrollment.delay') + def test_handle_enroll_status_change(self, mock_update_course_enrollment, mock_get_current_request): + """ + Test that the enroll status change signal handler properly calls the task routine + """ + # should just return if no current request found + mock_get_current_request.return_value = None + handle_enroll_status_change(None) + self.assertFalse(mock_update_course_enrollment.called) + + # now test with current request + mock_get_current_request.return_value = self.request + self.request.COOKIES['sailthru_bid'] = 'cookie_bid' + handle_enroll_status_change(None, event=EnrollStatusChange.enroll, + user=self.user, + mode='audit', course_id=self.course_id, + cost=None, currency=None) + self.assertTrue(mock_update_course_enrollment.called) + mock_update_course_enrollment.assert_called_with(TEST_EMAIL, + self.course_url, + EnrollStatusChange.enroll, + 'audit', + course_id=self.course_id, + currency=None, + message_id='cookie_bid', + unit_cost=None) + @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] @@ -162,6 +277,229 @@ class EmailMarketingTests(TestCase): 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.purchase') + @patch('email_marketing.tasks.SailthruClient.api_get') + @patch('email_marketing.tasks.SailthruClient.api_post') + @patch('email_marketing.tasks.get_course_by_id') + def test_update_course_enrollment(self, mock_get_course, mock_sailthru_api_post, + mock_sailthru_api_get, mock_sailthru_purchase, mock_log_error): + """ + test async method in task posts enrolls and purchases + """ + + CourseMode.objects.create( + course_id=self.course_id, + mode_slug=CourseMode.AUDIT, + mode_display_name=CourseMode.AUDIT + ) + CourseMode.objects.create( + course_id=self.course_id, + mode_slug=CourseMode.VERIFIED, + mode_display_name=CourseMode.VERIFIED, + min_price=49, + expiration_datetime=datetime.date(2020, 3, 12) + ) + mock_get_course.return_value = {'display_name': "Test Title"} + mock_sailthru_api_post.return_value = SailthruResponse(JsonResponse({'ok': True})) + mock_sailthru_api_get.return_value = SailthruResponse(JsonResponse({'vars': {'unenrolled': ['course_u1']}})) + mock_sailthru_purchase.return_value = SailthruResponse(JsonResponse({'ok': True})) + + # test enroll + mock_get_course.side_effect = Http404 + update_course_enrollment.delay(TEST_EMAIL, + self.course_url, + EnrollStatusChange.enroll, + 'audit', + course_id=self.course_id, + currency='USD', + message_id='cookie_bid', + unit_cost=0) + mock_sailthru_purchase.assert_called_with(TEST_EMAIL, [{'vars': {'course_run_id': self.course_id_string, 'mode': 'audit', + 'upgrade_deadline_verified': '2020-03-12'}, + 'title': 'Course ' + self.course_id_string + ' mode: audit', + 'url': self.course_url, + 'price': 100, 'qty': 1, 'id': self.course_id_string + '-audit'}], + options={'send_template': 'enroll_template'}, + incomplete=None, message_id='cookie_bid') + + # test unenroll + update_course_enrollment.delay(TEST_EMAIL, + self.course_url, + EnrollStatusChange.unenroll, + 'audit', + course_id=self.course_id, + currency='USD', + message_id='cookie_bid', + unit_cost=0) + mock_sailthru_purchase.assert_called_with(TEST_EMAIL, [{'vars': {'course_run_id': self.course_id_string, 'mode': 'audit', + 'upgrade_deadline_verified': '2020-03-12'}, + 'title': 'Course ' + self.course_id_string + ' mode: audit', + 'url': self.course_url, + 'price': 100, 'qty': 1, 'id': self.course_id_string + '-audit'}], + options={'send_template': 'enroll_template'}, + incomplete=None, message_id='cookie_bid') + + # test add upgrade to cart + update_course_enrollment.delay(TEST_EMAIL, + self.course_url, + EnrollStatusChange.upgrade_start, + 'verified', + course_id=self.course_id, + currency='USD', + message_id='cookie_bid', + unit_cost=49) + mock_sailthru_purchase.assert_called_with(TEST_EMAIL, [{'vars': {'course_run_id': self.course_id_string, 'mode': 'verified', + 'upgrade_deadline_verified': '2020-03-12'}, + 'title': 'Course ' + self.course_id_string + ' mode: verified', + 'url': self.course_url, + 'price': 4900, 'qty': 1, 'id': self.course_id_string + '-verified'}], + options={'reminder_template': 'abandoned_template', 'reminder_time': '+60 minutes'}, + incomplete=1, message_id='cookie_bid') + + # test add purchase to cart + update_course_enrollment.delay(TEST_EMAIL, + self.course_url, + EnrollStatusChange.paid_start, + 'honor', + course_id=self.course_id, + currency='USD', + message_id='cookie_bid', + unit_cost=49) + mock_sailthru_purchase.assert_called_with(TEST_EMAIL, [{'vars': {'course_run_id': self.course_id_string, 'mode': 'honor', + 'upgrade_deadline_verified': '2020-03-12'}, + 'title': 'Course ' + self.course_id_string + ' mode: honor', + 'url': self.course_url, + 'price': 4900, 'qty': 1, 'id': self.course_id_string + '-honor'}], + options={'reminder_template': 'abandoned_template', 'reminder_time': '+60 minutes'}, + incomplete=1, message_id='cookie_bid') + + # test purchase complete + update_course_enrollment.delay(TEST_EMAIL, + self.course_url, + EnrollStatusChange.paid_complete, + 'honor', + course_id=self.course_id, + currency='USD', + message_id='cookie_bid', + unit_cost=99) + mock_sailthru_purchase.assert_called_with(TEST_EMAIL, [{'vars': {'course_run_id': self.course_id_string, 'mode': 'honor', + 'upgrade_deadline_verified': '2020-03-12'}, + 'title': 'Course ' + self.course_id_string + ' mode: honor', + 'url': self.course_url, + 'price': 9900, 'qty': 1, 'id': self.course_id_string + '-honor'}], + options={'send_template': 'purchase_template'}, + incomplete=None, message_id='cookie_bid') + + # test upgrade complete + update_course_enrollment.delay(TEST_EMAIL, + self.course_url, + EnrollStatusChange.upgrade_complete, + 'verified', + course_id=self.course_id, + currency='USD', + message_id='cookie_bid', + unit_cost=99) + mock_sailthru_purchase.assert_called_with(TEST_EMAIL, [{'vars': {'course_run_id': self.course_id_string, 'mode': 'verified', + 'upgrade_deadline_verified': '2020-03-12'}, + 'title': 'Course ' + self.course_id_string + ' mode: verified', + 'url': self.course_url, + 'price': 9900, 'qty': 1, 'id': self.course_id_string + '-verified'}], + options={'send_template': 'upgrade_template'}, + incomplete=None, message_id='cookie_bid') + + # test purchase API error + mock_sailthru_purchase.return_value = SailthruResponse(JsonResponse({'error': 100, 'errormsg': 'Got an error'})) + update_course_enrollment.delay(TEST_EMAIL, + self.course_url, + EnrollStatusChange.upgrade_complete, + 'verified', + course_id=self.course_id, + currency='USD', + message_id='cookie_bid', + unit_cost=99) + self.assertTrue(mock_log_error.called) + + # test purchase API exception + mock_sailthru_purchase.side_effect = SailthruClientError + update_course_enrollment.delay(TEST_EMAIL, + self.course_url, + EnrollStatusChange.upgrade_complete, + 'verified', + course_id=self.course_id, + currency='USD', + message_id='cookie_bid', + unit_cost=99) + self.assertTrue(mock_log_error.called) + + @patch('email_marketing.tasks.SailthruClient') + def test_get_course_content(self, mock_sailthru_client): + """ + test routine which fetches data from Sailthru content api + """ + mock_sailthru_client.api_get.return_value = SailthruResponse(JsonResponse({"title": "The title"})) + response_json = _get_course_content('course:123', mock_sailthru_client, EmailMarketingConfiguration.current()) + self.assertEquals(response_json, {"title": "The title"}) + mock_sailthru_client.api_get.assert_called_with('content', {'id': 'course:123'}) + + # test second call uses cache + response_json = _get_course_content('course:123', mock_sailthru_client, EmailMarketingConfiguration.current()) + self.assertEquals(response_json, {"title": "The title"}) + mock_sailthru_client.api_get.assert_not_called() + + # test error from Sailthru + mock_sailthru_client.api_get.return_value = \ + SailthruResponse(JsonResponse({'error': 100, 'errormsg': 'Got an error'})) + self.assertEquals(_get_course_content('course:124', mock_sailthru_client, EmailMarketingConfiguration.current()), {}) + + # test exception + mock_sailthru_client.api_get.side_effect = SailthruClientError + self.assertEquals(_get_course_content('course:125', mock_sailthru_client, EmailMarketingConfiguration.current()), {}) + + @patch('email_marketing.tasks.SailthruClient') + def test_update_unenrolled_list(self, mock_sailthru_client): + """ + test routine which updates the unenrolled list in Sailthru + """ + + # test a new unenroll + mock_sailthru_client.api_get.return_value = \ + SailthruResponse(JsonResponse({'vars': {'unenrolled': ['course_u1']}})) + self.assertTrue(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, + self.course_url, True)) + mock_sailthru_client.api_get.assert_called_with("user", {"id": TEST_EMAIL, "fields": {"vars": 1}}) + mock_sailthru_client.api_post.assert_called_with('user', + {'vars': {'unenrolled': ['course_u1', self.course_url]}, + 'id': TEST_EMAIL, 'key': 'email'}) + + # test an enroll of a previously unenrolled course + mock_sailthru_client.api_get.return_value = \ + SailthruResponse(JsonResponse({'vars': {'unenrolled': [self.course_url]}})) + self.assertTrue(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, + self.course_url, False)) + mock_sailthru_client.api_post.assert_called_with('user', + {'vars': {'unenrolled': []}, + 'id': TEST_EMAIL, 'key': 'email'}) + + # test get error from Sailthru + mock_sailthru_client.api_get.return_value = \ + SailthruResponse(JsonResponse({'error': 100, 'errormsg': 'Got an error'})) + self.assertFalse(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, + self.course_url, False)) + + # test post error from Sailthru + mock_sailthru_client.api_post.return_value = \ + SailthruResponse(JsonResponse({'error': 100, 'errormsg': 'Got an error'})) + mock_sailthru_client.api_get.return_value = \ + SailthruResponse(JsonResponse({'vars': {'unenrolled': [self.course_url]}})) + self.assertFalse(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, + self.course_url, False)) + + # test exception + mock_sailthru_client.api_get.side_effect = SailthruClientError + self.assertFalse(_update_unenrolled_list(mock_sailthru_client, TEST_EMAIL, + self.course_url, False)) + @patch('email_marketing.tasks.log.error') @patch('email_marketing.tasks.SailthruClient.api_post') def test_error_logging1(self, mock_sailthru, mock_log_error): @@ -172,6 +510,10 @@ class EmailMarketingTests(TestCase): update_user_email.delay(self.user.username, "newemail2@test.com") self.assertTrue(mock_log_error.called) + mock_sailthru.side_effect = SailthruClientError + 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): """ diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 2f000babc7c13e8cdf69b6ce0da9fb5f5dee84c9..5c728a52f7273a767c0ea38cc017aef540435e4e 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -38,7 +38,7 @@ from courseware.courses import get_course_by_id from config_models.models import ConfigurationModel from course_modes.models import CourseMode from edxmako.shortcuts import render_to_string -from student.models import CourseEnrollment, UNENROLL_DONE +from student.models import CourseEnrollment, UNENROLL_DONE, EnrollStatusChange from util.query import use_read_replica_if_available from xmodule_django.models import CourseKeyField from .exceptions import ( @@ -1587,6 +1587,10 @@ class PaidCourseRegistration(OrderItem): item.save() log.info("User {} added course registration {} to cart: order {}" .format(order.user.email, course_id, order.id)) + + CourseEnrollment.send_signal_full(EnrollStatusChange.paid_start, + user=order.user, mode=item.mode, course_id=course_id, + cost=cost, currency=currency) return item def purchased_callback(self): @@ -1607,6 +1611,8 @@ class PaidCourseRegistration(OrderItem): log.info("Enrolled {0} in paid course {1}, paid ${2}" .format(self.user.email, self.course_id, self.line_cost)) + self.course_enrollment.send_signal(EnrollStatusChange.paid_complete, + cost=self.line_cost, currency=self.currency) def generate_receipt_instructions(self): """ @@ -1977,6 +1983,9 @@ class CertificateItem(OrderItem): order.currency = currency order.save() item.save() + + # signal course added to cart + course_enrollment.send_signal(EnrollStatusChange.paid_start, cost=cost, currency=currency) return item def purchased_callback(self): @@ -1985,6 +1994,8 @@ class CertificateItem(OrderItem): """ self.course_enrollment.change_mode(self.mode) self.course_enrollment.activate() + self.course_enrollment.send_signal(EnrollStatusChange.upgrade_complete, + cost=self.unit_cost, currency=self.currency) def additional_instruction_text(self): verification_reminder = ""