From aa078dfda2c07c6d5a0bde8f0bfd712ba9f33349 Mon Sep 17 00:00:00 2001
From: PaulWattenberger <paul@baadlands.com>
Date: Tue, 28 Jun 2016 11:13:21 -0400
Subject: [PATCH] Pwattenberger/sailthru enroll (#12816)

* Partial changes for purchase tracking

* Continued changes for purchase tracking

* Clean up code quality issues

* Clean up code quality issues

* Responses to code review

* Fix code quality flaged issues

* Fix code quality flaged issues

* Fix code quality flaged issues

* Fix problem processing sailthru_content cookie
---
 common/djangoapps/student/models.py           |  41 ++
 common/djangoapps/student/views.py            |   5 +-
 .../migrations/0002_auto_20160623_1656.py     |  59 +++
 lms/djangoapps/email_marketing/models.py      |  64 +++
 lms/djangoapps/email_marketing/signals.py     |  63 ++-
 lms/djangoapps/email_marketing/tasks.py       | 284 +++++++++++++-
 .../email_marketing/tests/test_signals.py     | 370 +++++++++++++++++-
 lms/djangoapps/shoppingcart/models.py         |  13 +-
 8 files changed, 863 insertions(+), 36 deletions(-)
 create mode 100644 lms/djangoapps/email_marketing/migrations/0002_auto_20160623_1656.py

diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 6e6c24446b8..45bb422c6c1 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 d94113f9e7a..186203f95de 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 00000000000..4761335a6cb
--- /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 72f517b38e7..d31b71f73bc 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 e5504fc6233..b5979ddec03 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 b7bae0d5463..4c9c5723c9f 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 c39f7700be8..3adef8d7a39 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 2f000babc7c..5c728a52f72 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 = ""
-- 
GitLab