Files
edx-platform/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py
2014-04-28 16:47:17 -04:00

264 lines
11 KiB
Python

"""
Send emails to users inviting them to add their course certificates to their
LinkedIn profiles.
"""
from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError, SMTPException
import json
import logging
import urllib
from boto.exception import AWSConnectionError
from boto.ses.exceptions import (
SESAddressNotVerifiedError,
SESIdentityNotVerifiedError,
SESDomainNotConfirmedError,
SESAddressBlacklistedError,
SESDailyQuotaExceededError,
SESMaxSendingRateExceededError,
SESDomainEndsWithDotError,
SESLocalAddressCharacterError,
SESIllegalAddressError,
)
from django.conf import settings
from django.core.mail import EmailMessage
from django.core.management.base import BaseCommand
from django.db import transaction
from django.template import Context
from django.template.loader import get_template
from django.core.urlresolvers import reverse
from optparse import make_option
from edxmako.shortcuts import render_to_string
from certificates.models import GeneratedCertificate
from courseware.courses import get_course_by_id, course_image_url
from ...models import LinkedIn
# The following is blatantly cribbed from bulk_email/tasks.py
# Errors that an individual email is failing to be sent, and should just
# be treated as a fail.
SINGLE_EMAIL_FAILURE_ERRORS = (
SESAddressBlacklistedError, # Recipient's email address has been temporarily blacklisted.
SESDomainEndsWithDotError, # Recipient's email address' domain ends with a period/dot.
SESIllegalAddressError, # Raised when an illegal address is encountered.
SESLocalAddressCharacterError, # An address contained a control or whitespace character.
)
# Exceptions that, if caught, should cause the task to be re-tried.
# These errors will be caught a limited number of times before the task fails.
LIMITED_RETRY_ERRORS = (
SMTPConnectError,
SMTPServerDisconnected,
AWSConnectionError,
)
# Errors that indicate that a mailing task should be retried without limit.
# An example is if email is being sent too quickly, but may succeed if sent
# more slowly. When caught by a task, it triggers an exponential backoff and retry.
# Retries happen continuously until the email is sent.
# Note that the SMTPDataErrors here are only those within the 4xx range.
# Those not in this range (i.e. in the 5xx range) are treated as hard failures
# and thus like SINGLE_EMAIL_FAILURE_ERRORS.
INFINITE_RETRY_ERRORS = (
SESMaxSendingRateExceededError, # Your account's requests/second limit has been exceeded.
SMTPDataError,
)
# Errors that are known to indicate an inability to send any more emails,
# and should therefore not be retried. For example, exceeding a quota for emails.
# Also, any SMTP errors that are not explicitly enumerated above.
BULK_EMAIL_FAILURE_ERRORS = (
SESAddressNotVerifiedError, # Raised when a "Reply-To" address has not been validated in SES yet.
SESIdentityNotVerifiedError, # Raised when an identity has not been verified in SES yet.
SESDomainNotConfirmedError, # Raised when domain ownership is not confirmed for DKIM.
SESDailyQuotaExceededError, # 24-hour allotment of outbound email has been exceeded.
SMTPException,
)
MAX_ATTEMPTS = 10
log = logging.getLogger("linkedin")
class Command(BaseCommand):
"""
Django command for inviting users to add their course certificates to their
LinkedIn profiles.
"""
args = ''
help = ('Sends emails to edX users that are on LinkedIn who have completed '
'course certificates, inviting them to add their certificates to '
'their LinkedIn profiles')
option_list = BaseCommand.option_list + (
make_option(
'--mock',
action='store_true',
dest='mock_run',
default=False,
help="Run without sending the final e-mails."),)
def __init__(self):
super(Command, self).__init__()
@transaction.commit_manually
def handle(self, *args, **options):
whitelist = settings.LINKEDIN_API['EMAIL_WHITELIST']
mock_run = options.get('mock_run', False)
accounts = LinkedIn.objects.filter(has_linkedin_account=True)
for account in accounts:
user = account.user
if whitelist and user.email not in whitelist:
# Whitelist only certain addresses for testing purposes
continue
try:
emailed = json.loads(account.emailed_courses)
except Exception:
log.exception("LinkedIn: Could not parse emailed_courses for {}".format(user.username))
continue
certificates = GeneratedCertificate.objects.filter(user=user)
certificates = certificates.filter(status='downloadable')
certificates = [cert for cert in certificates if cert.course_id not in emailed]
# Shouldn't happen, since we're only picking users who have
# certificates, but just in case...
if not certificates:
log.info("LinkedIn: No certificates for user {}".format(user.username))
continue
# Basic sanity checks passed, now try to send the emails
try:
success = False
success = self.send_grandfather_email(user, certificates, mock_run)
log.info("LinkedIn: Sent email for user {}".format(user.username))
if not mock_run:
emailed.extend([cert.course_id for cert in certificates])
if success and not mock_run:
account.emailed_courses = json.dumps(emailed)
account.save()
transaction.commit()
except BULK_EMAIL_FAILURE_ERRORS:
log.exception("LinkedIn: No further email sending will work, aborting")
transaction.commit()
return -1
except Exception:
log.exception("LinkedIn: User {} couldn't be processed".format(user.username))
transaction.commit()
def certificate_url(self, certificate):
"""
Generates a certificate URL based on LinkedIn's documentation. The
documentation is from a Word document: DAT_DOCUMENTATION_v3.12.docx
"""
course = get_course_by_id(certificate.course_id)
tracking_code = '-'.join([
'eml',
'prof', # the 'product'--no idea what that's supposed to mean
'edX', # Partner's name
course.number, # Certificate's name
'gf'])
query = [
('pfCertificationName', course.display_name_with_default),
('pfAuthorityId', settings.LINKEDIN_API['COMPANY_ID']),
('pfCertificationUrl', certificate.download_url),
('pfLicenseNo', certificate.course_id),
('pfCertStartDate', course.start.strftime('%Y%m')),
('_mSplash', '1'),
('trk', tracking_code),
('startTask', 'CERTIFICATION_NAME'),
('force', 'true')]
return 'http://www.linkedin.com/profile/guided?' + urllib.urlencode(query)
def send_grandfather_email(self, user, certificates, mock_run=False):
"""
Send the 'grandfathered' email informing historical students that they
may now post their certificates on their LinkedIn profiles.
"""
courses_list = []
for cert in certificates:
course = get_course_by_id(cert.course_id)
course_url = 'https://{}{}'.format(
settings.SITE_NAME,
reverse('course_root', kwargs={'course_id': cert.course_id})
)
course_title = course.display_name_with_default
course_img_url = 'https://{}{}'.format(settings.SITE_NAME, course_image_url(course))
course_end_date = course.end.strftime('%b %Y')
course_org = course.org
courses_list.append({
'course_url': course_url,
'course_org': course_org,
'course_title': course_title,
'course_image_url': course_img_url,
'course_end_date': course_end_date,
'linkedin_add_url': self.certificate_url(cert),
})
context = {
'courses_list': courses_list,
'num_courses': len(courses_list),
'google_analytics': settings.GOOGLE_ANALYTICS_LINKEDIN,
}
body = render_to_string('linkedin/linkedin_email.html', context)
subject = u'{}, Add your Achievements to your LinkedIn Profile'.format(user.profile.name)
if mock_run:
return True
else:
return self.send_email(user, subject, body)
def send_email(self, user, subject, body, num_attempts=MAX_ATTEMPTS):
"""
Send an email. Return True if it succeeded, False if it didn't.
"""
fromaddr = u'no-reply@notifier.edx.org'
toaddr = u'{} <{}>'.format(user.profile.name, user.email)
msg = EmailMessage(subject, body, fromaddr, (toaddr,))
msg.content_subtype = "html"
i = 1
while i <= num_attempts:
try:
msg.send()
return True # Happy path!
except SINGLE_EMAIL_FAILURE_ERRORS:
# Something unrecoverable is wrong about the email acct we're sending to
log.exception(
u"LinkedIn: Email send failed for user {}, email {}"
.format(user.username, user.email)
)
return False
except LIMITED_RETRY_ERRORS:
# Something went wrong (probably an intermittent connection error),
# but maybe if we beat our heads against the wall enough times,
# we can crack our way through. Thwack! Thwack! Thwack!
# Give up after num_attempts though (for loop exits), let's not
# get carried away.
log.exception(
u"LinkedIn: Email send for user {}, email {}, encountered error, attempt #{}"
.format(user.username, user.email, i)
)
i += 1
continue
except INFINITE_RETRY_ERRORS:
# Dude, it will *totally* work if I just... sleep... a little...
# Things like max send rate exceeded. The smart thing would be
# to do exponential backoff. The lazy thing to do would be just
# sleep some arbitrary amount and trust that it'll probably work.
# GUESS WHAT WE'RE DOING BOYS AND GIRLS!?!
log.exception("LinkedIn: temporary error encountered, retrying")
time.sleep(1)
# If we hit here, we went through all our attempts without success
return False