Skip to content
Snippets Groups Projects
Commit aa078dfd authored by PaulWattenberger's avatar PaulWattenberger Committed by GitHub
Browse files

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
parent 664cafc9
No related merge requests found
......@@ -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.
......
......@@ -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"))
......
# -*- 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),
),
]
......@@ -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)
......@@ -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
......
......@@ -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
This diff is collapsed.
......@@ -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 = ""
......
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment