tasks.py 13.3 KB
Newer Older
1 2 3 4 5 6 7
"""
This file contains celery tasks for email marketing signal handler.
"""
import logging
import time

from celery import task
8
from django.core.cache import cache
9 10

from email_marketing.models import EmailMarketingConfiguration
11
from student.models import EnrollStatusChange
12 13 14 15 16 17 18 19 20

from sailthru.sailthru_client import SailthruClient
from sailthru.sailthru_error import SailthruClientError

log = logging.getLogger(__name__)


# pylint: disable=not-callable
@task(bind=True, default_retry_delay=3600, max_retries=24)
21
def update_user(self, sailthru_vars, email, new_user=False, activation=False):
22 23 24
    """
    Adds/updates Sailthru profile information for a user.
     Args:
25 26 27 28
        sailthru_vars(dict): User profile information to pass as 'vars' to Sailthru
        email(str): User email address
        new_user(boolean): True if new registration
        activation(boolean): True if activation request
29 30 31 32 33 34 35 36 37 38
    Returns:
        None
    """
    email_config = EmailMarketingConfiguration.current()
    if not email_config.enabled:
        return

    sailthru_client = SailthruClient(email_config.sailthru_key, email_config.sailthru_secret)
    try:
        sailthru_response = sailthru_client.api_post("user",
39 40 41
                                                     _create_sailthru_user_parm(sailthru_vars, email,
                                                                                new_user, email_config))

42
    except SailthruClientError as exc:
43
        log.error("Exception attempting to add/update user %s in Sailthru - %s", email, unicode(exc))
44 45 46 47 48 49 50
        raise self.retry(exc=exc,
                         countdown=email_config.sailthru_retry_interval,
                         max_retries=email_config.sailthru_max_retries)

    if not sailthru_response.is_ok():
        error = sailthru_response.get_error()
        log.error("Error attempting to add/update user in Sailthru: %s", error.get_message())
51 52 53 54
        if _retryable_sailthru_error(error):
            raise self.retry(countdown=email_config.sailthru_retry_interval,
                             max_retries=email_config.sailthru_max_retries)
        return
55 56 57 58 59

    # if activating user, send welcome email
    if activation and email_config.sailthru_activation_template:
        try:
            sailthru_response = sailthru_client.api_post("send",
60
                                                         {"email": email,
61 62
                                                          "template": email_config.sailthru_activation_template})
        except SailthruClientError as exc:
63
            log.error("Exception attempting to send welcome email to user %s in Sailthru - %s", email, unicode(exc))
64 65 66 67 68 69 70
            raise self.retry(exc=exc,
                             countdown=email_config.sailthru_retry_interval,
                             max_retries=email_config.sailthru_max_retries)

        if not sailthru_response.is_ok():
            error = sailthru_response.get_error()
            log.error("Error attempting to send welcome email to user in Sailthru: %s", error.get_message())
71 72 73
            if _retryable_sailthru_error(error):
                raise self.retry(countdown=email_config.sailthru_retry_interval,
                                 max_retries=email_config.sailthru_max_retries)
74 75 76 77


# pylint: disable=not-callable
@task(bind=True, default_retry_delay=3600, max_retries=24)
78
def update_user_email(self, new_email, old_email):
79 80 81 82 83 84 85 86 87 88 89 90 91
    """
    Adds/updates Sailthru when a user email address is changed
     Args:
        username(str): A string representation of user identifier
        old_email(str): Original email address
    Returns:
        None
    """
    email_config = EmailMarketingConfiguration.current()
    if not email_config.enabled:
        return

    # ignore if email not changed
92
    if new_email == old_email:
93 94
        return

95
    sailthru_parms = {"id": old_email, "key": "email", "keysconflict": "merge", "keys": {"email": new_email}}
96 97 98 99 100

    try:
        sailthru_client = SailthruClient(email_config.sailthru_key, email_config.sailthru_secret)
        sailthru_response = sailthru_client.api_post("user", sailthru_parms)
    except SailthruClientError as exc:
101
        log.error("Exception attempting to update email for %s in Sailthru - %s", old_email, unicode(exc))
102 103 104 105 106 107 108
        raise self.retry(exc=exc,
                         countdown=email_config.sailthru_retry_interval,
                         max_retries=email_config.sailthru_max_retries)

    if not sailthru_response.is_ok():
        error = sailthru_response.get_error()
        log.error("Error attempting to update user email address in Sailthru: %s", error.get_message())
109 110 111
        if _retryable_sailthru_error(error):
            raise self.retry(countdown=email_config.sailthru_retry_interval,
                             max_retries=email_config.sailthru_max_retries)
112 113


114
def _create_sailthru_user_parm(sailthru_vars, email, new_user, email_config):
115
    """
116
    Create sailthru user create/update parms
117
    """
118 119
    sailthru_user = {'id': email, 'key': 'email'}
    sailthru_user['vars'] = dict(sailthru_vars, last_changed_time=int(time.time()))
120 121 122 123 124 125

    # if new user add to list
    if new_user and email_config.sailthru_new_user_list:
        sailthru_user['lists'] = {email_config.sailthru_new_user_list: 1}

    return sailthru_user
126 127 128 129 130


# pylint: disable=not-callable
@task(bind=True, default_retry_delay=3600, max_retries=24)
def update_course_enrollment(self, email, course_url, event, mode,
131
                             course_id=None, message_id=None):  # pylint: disable=unused-argument
132 133 134 135 136 137
    """
    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
138
        mode(str): enroll mode (audit, verification, ...)
139
        unit_cost: cost if purchase event
140
        course_id(str): course run id
141 142 143 144 145 146 147
        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
148
            A free enroll (mode=audit or honor)
149 150 151
        EnrollStatusChange.unenroll
            An unenroll
        EnrollStatusChange.upgrade_start
152
            A paid upgrade added to cart - ignored
153
        EnrollStatusChange.upgrade_complete
154
            A paid upgrade purchase complete - ignored
155
        EnrollStatusChange.paid_start
156
            A non-free course added to cart - ignored
157
        EnrollStatusChange.paid_complete
158
            A non-free course purchase complete - ignored
159 160 161 162 163 164 165
    """

    email_config = EmailMarketingConfiguration.current()
    if not email_config.enabled:
        return

    # Use event type to figure out processing required
166 167 168
    unenroll = False
    send_template = None
    cost_in_cents = 0
169 170 171

    if event == EnrollStatusChange.enroll:
        send_template = email_config.sailthru_enroll_template
172
        # set cost so that Sailthru recognizes the event
173 174 175 176 177 178
        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

179 180 181
    else:
        # All purchase events should be handled by ecommerce, so ignore
        return
182 183 184

    sailthru_client = SailthruClient(email_config.sailthru_key, email_config.sailthru_secret)

185 186 187 188
    # update the "unenrolled" course array in the user record on Sailthru
    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)
189 190 191 192 193

    # if there is a cost, call Sailthru purchase api to record
    if cost_in_cents:

        # get course information if configured and appropriate event
194 195
        course_data = {}
        if email_config.sailthru_get_tags_from_sailthru:
196 197 198
            course_data = _get_course_content(course_url, sailthru_client, email_config)

        # build item description
199
        item = _build_purchase_item(course_id, course_url, cost_in_cents, mode, course_data)
200 201 202 203 204 205 206 207

        # build purchase api options list
        options = {}

        # add appropriate send template
        if send_template:
            options['send_template'] = send_template

208
        if not _record_purchase(sailthru_client, email, item, message_id, options):
209 210 211 212
            raise self.retry(countdown=email_config.sailthru_retry_interval,
                             max_retries=email_config.sailthru_max_retries)


213
def _build_purchase_item(course_id_string, course_url, cost_in_cents, mode, course_data):
214 215 216 217 218 219 220 221 222 223 224 225 226
    """
    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,
    }

227
    # make up title if we don't already have it from Sailthru
228 229 230
    if 'title' in course_data:
        item['title'] = course_data['title']
    else:
231
        item['title'] = 'Course {} mode: {}'.format(course_id_string, mode)
232 233 234 235 236

    if 'tags' in course_data:
        item['tags'] = course_data['tags']

    # add vars to item
237
    item['vars'] = dict(course_data.get('vars', {}), mode=mode, course_run_id=course_id_string)
238 239 240 241

    return item


242
def _record_purchase(sailthru_client, email, item, message_id, options):
243 244 245 246 247 248 249 250 251 252 253 254
    """
    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],
255
                                                     message_id=message_id,
256 257 258 259 260
                                                     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())
261
            return not _retryable_sailthru_error(error)
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311

    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()
312 313
            log.info("Error attempting to read user record from Sailthru: %s", error.get_message())
            return not _retryable_sailthru_error(error)
314 315 316 317

        response_json = sailthru_response.json

        unenroll_list = []
318 319
        if response_json and "vars" in response_json and response_json["vars"] \
                and "unenrolled" in response_json["vars"]:
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
            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()
341 342
                log.info("Error attempting to update user record in Sailthru: %s", error.get_message())
                return not _retryable_sailthru_error(error)
343 344 345 346 347 348 349

        # 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
350 351 352 353 354 355 356 357 358 359 360 361 362


def _retryable_sailthru_error(error):
    """ Return True if error should be retried.

    9: Retryable internal error
    43: Rate limiting response
    others: Not retryable

    See: https://getstarted.sailthru.com/new-for-developers-overview/api/api-response-errors/
    """
    code = error.get_error_code()
    return code == 9 or code == 43