Remove LinkedIn integration
This commit is contained in:
@@ -1,121 +0,0 @@
|
||||
============================
|
||||
LinkedIn Integration for edX
|
||||
============================
|
||||
|
||||
This package provides a Django application for use with the edX platform which
|
||||
allows users to post their earned certificates on their LinkedIn profiles. All
|
||||
functionality is currently provided via a command line interface intended to be
|
||||
used by a system administrator and called from other scripts.
|
||||
|
||||
Basic Flow
|
||||
----------
|
||||
|
||||
The basic flow is as follows:
|
||||
|
||||
o A system administrator uses the 'linkedin_login' script to log in to LinkedIn
|
||||
as a user with email lookup access in the People API. This provides an access
|
||||
token that can be used by the 'linkedin_findusers' script to check for users
|
||||
that have LinkedIn accounts.
|
||||
|
||||
o A system administrator (or cron job, etc...) runs the 'linkedin_findusers'
|
||||
script to query the LinkedIn People API, looking for users of edX which have
|
||||
accounts on LinkedIn.
|
||||
|
||||
o A system administrator (or cron job, etc...) runs the 'linkedin_mailusers'
|
||||
script. This scripts finds all users with LinkedIn accounts who also have
|
||||
certificates they've earned which they haven't already been emailed about.
|
||||
Users are then emailed links to add their certificates to their LinkedIn
|
||||
accounts.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
To use this application, first add it to your `INSTALLED_APPS` setting in your
|
||||
environment config::
|
||||
|
||||
INSTALLED_APPS += ('linkedin',)
|
||||
|
||||
You will then also need to provide a new key in your settings, `LINKEDIN_API`,
|
||||
which is a dictionary::
|
||||
|
||||
LINKEDIN_API = {
|
||||
# Needed for API calls
|
||||
'CLIENT_ID': "FJkdfj93kf93",
|
||||
'CLIENT_SECRET': "FJ93oldj939rkfj39",
|
||||
'REDIRECT_URI': "http://my.org.foo",
|
||||
|
||||
# Needed to generate certificate links
|
||||
'COMPANY_NAME': 'Foo',
|
||||
'COMPANY_ID': "1234567",
|
||||
|
||||
# Needed for sending emails
|
||||
'EMAIL_FROM': "The Team <someone@org.foo>",
|
||||
'EMAIL_WHITELIST': set(['fred@bedrock.gov', 'barney@bedrock.gov'])
|
||||
}
|
||||
|
||||
`CLIENT_ID`, `CLIENT_SECRET`, and `REDIRECT_URI` all come from your registration
|
||||
with LinkedIn for API access. `CLIENT_ID` and `CLIENT_SECRET` will be provied
|
||||
to you by LinkedIn. You will choose `REDIRECT_URI`, and it will be the URI
|
||||
users are directed to after handling the authorization flow for logging into
|
||||
LinkedIn and getting an access token.
|
||||
|
||||
`COMPANY_NAME` is the name of the LinkedIn profile for the company issuing the
|
||||
certificate, e.g. 'edX'. `COMPANY_ID` is the LinkedIn ID for the same profile.
|
||||
This can be found in the URL for the company profile. For exampled, edX's
|
||||
LinkedIn profile is found at the URL: http://www.linkedin.com/company/2746406
|
||||
and their `COMPANY_ID` is 2746406.
|
||||
|
||||
`EMAIL_FROM` just sets the from address that is used for generated emails.
|
||||
|
||||
`EMAIL_WHITELIST` is optional and intended for use in testing. If
|
||||
`EMAIL_WHITELIST` is given, only users whose email is in the whitelest will get
|
||||
notification emails. All others will be skipped. Do not provide this in
|
||||
production.
|
||||
|
||||
If you are adding this application to an already running instance of edX, you
|
||||
will need to use the `syncdb` script to add the tables used by this application
|
||||
to the database.
|
||||
|
||||
Logging into LinkedIn
|
||||
---------------------
|
||||
|
||||
The management script, `linkedin_login`, interactively guides a user to log into
|
||||
LinkedIn and obtain an access token. The script generates an authorization URL,
|
||||
asks the user go to that URL in their web browser and log in via LinkedIn's web
|
||||
UI. When the user has done that, they will be redirected to the configured
|
||||
location with an authorization token embedded in the query string of the URL.
|
||||
This authorization token is good for only 30 seconds. Within 30 seconds the
|
||||
user should copy and paste the URL they were directed to back into the command
|
||||
line script, which will then obtain and store an access token.
|
||||
|
||||
Access tokens are good for 60 days. There is currently no way to refresh an
|
||||
access token without rerunning the `linkedin_login` script again.
|
||||
|
||||
Finding Users
|
||||
-------------
|
||||
|
||||
Once you have logged in, the management script, `linkedin_findusers`, is used
|
||||
to find out which users have LinkedIn accounts using LinkedIn's People API. By
|
||||
default only users which have never been checked are checked. The `--recheck`
|
||||
option can be provided to recheck all users, in case some users have joined
|
||||
LinkedIn since the last time they were checked.
|
||||
|
||||
LinkedIn has provided guidance on what limits we should follow in accessing
|
||||
their API based on time of the day and day of the week. The script attempts to
|
||||
enforce that. To override its enforcement, you can provide the `--force` flag.
|
||||
|
||||
Send Emails
|
||||
-----------
|
||||
|
||||
Once you have found users, you can email them links for their earned
|
||||
certificates using the `linkedin_mailusers` script. The script will only mail
|
||||
any particular user once for any particular certificate they have earned.
|
||||
|
||||
The emails come in two distinct flavors: triggered and grandfathered. Triggered
|
||||
emails are the default. These comprise one email per earned certificate and are
|
||||
intended for use when a user has recently earned a certificate, as will
|
||||
generally be the case if this script is run regularly.
|
||||
|
||||
The grandfathered from of the email can be sent by adding the `--grandfather`
|
||||
flag and is intended to bring users up to speed with all of their earned
|
||||
certificates at once when this feature is first added to edX.
|
||||
@@ -1,261 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,70 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'LinkedIn'
|
||||
db.create_table('linkedin_linkedin', (
|
||||
('user', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['auth.User'], unique=True, primary_key=True)),
|
||||
('has_linkedin_account', self.gf('django.db.models.fields.NullBooleanField')(default=None, null=True, blank=True)),
|
||||
('emailed_courses', self.gf('django.db.models.fields.TextField')(default='[]')),
|
||||
))
|
||||
db.send_create_signal('linkedin', ['LinkedIn'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'LinkedIn'
|
||||
db.delete_table('linkedin_linkedin')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'linkedin.linkedin': {
|
||||
'Meta': {'object_name': 'LinkedIn'},
|
||||
'emailed_courses': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
|
||||
'has_linkedin_account': ('django.db.models.fields.NullBooleanField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'primary_key': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['linkedin']
|
||||
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
Models for LinkedIn integration app.
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
|
||||
|
||||
class LinkedIn(models.Model):
|
||||
"""
|
||||
Defines a table for storing a users's LinkedIn status.
|
||||
"""
|
||||
user = models.OneToOneField(User, primary_key=True)
|
||||
has_linkedin_account = models.NullBooleanField(default=None)
|
||||
emailed_courses = models.TextField(default="[]") # JSON list of course ids
|
||||
@@ -1,25 +0,0 @@
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<p>{% blocktrans with name=student_name %}
|
||||
Dear {{student_name}},
|
||||
{% endblocktrans %}</p>
|
||||
|
||||
<p>{% blocktrans with name=course_name %}
|
||||
Congratulations on earning your certificate in {{course_name}}!
|
||||
Since you have an account on LinkedIn, you can display your hard earned
|
||||
credential for your colleagues to see. Click the button below to add the
|
||||
certificate to your profile.
|
||||
{% endblocktrans %}</p>
|
||||
|
||||
<p><a href="{{url|safe}}">
|
||||
<span>in<span>
|
||||
{% blocktrans %}Add to profile{% endblocktrans %}
|
||||
</a></p>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user