Commit 42e0ec40 by David Ormsbee

Merge pull request #2251 from edx/release

Merging Release 2014-01-21
parents 8aa208e8 d220aaca
......@@ -37,13 +37,18 @@ def marketing_link(name):
# link_map maps URLs from the marketing site to the old equivalent on
# the Django site
link_map = settings.MKTG_URL_LINK_MAP
if settings.FEATURES.get('ENABLE_MKTG_SITE') and name in settings.MKTG_URLS:
enable_mktg_site = MicrositeConfiguration.get_microsite_configuration_value(
'ENABLE_MKTG_SITE',
settings.FEATURES.get('ENABLE_MKTG_SITE', False)
)
if enable_mktg_site and name in settings.MKTG_URLS:
# special case for when we only want the root marketing URL
if name == 'ROOT':
return settings.MKTG_URLS.get('ROOT')
return settings.MKTG_URLS.get('ROOT') + settings.MKTG_URLS.get(name)
# only link to the old pages when the marketing site isn't on
elif not settings.FEATURES.get('ENABLE_MKTG_SITE') and name in link_map:
elif not enable_mktg_site and name in link_map:
# don't try to reverse disabled marketing links
if link_map[name] is not None:
return reverse(link_map[name])
......
......@@ -59,7 +59,10 @@ def courses(request):
to that. Otherwise, if subdomain branding is on, this is the university
profile page. Otherwise, it's the edX courseware.views.courses page
"""
enable_mktg_site = settings.FEATURES.get('ENABLE_MKTG_SITE') or MicrositeConfiguration.get_microsite_configuration_value('ENABLE_MKTG_SITE', False)
enable_mktg_site = MicrositeConfiguration.get_microsite_configuration_value(
'ENABLE_MKTG_SITE',
settings.FEATURES.get('ENABLE_MKTG_SITE', False)
)
if enable_mktg_site:
return redirect(marketing_link('COURSES'), permanent=True)
......
......@@ -38,6 +38,8 @@ from xmodule.modulestore.search import path_to_location
from xmodule.course_module import CourseDescriptor
import shoppingcart
from microsite_configuration.middleware import MicrositeConfiguration
log = logging.getLogger("edx.courseware")
template_imports = {'urllib': urllib}
......@@ -514,7 +516,11 @@ def registered_for_course(course, user):
@ensure_csrf_cookie
@cache_if_anonymous
def course_about(request, course_id):
if settings.FEATURES.get('ENABLE_MKTG_SITE', False):
if MicrositeConfiguration.get_microsite_configuration_value(
'ENABLE_MKTG_SITE',
settings.FEATURES.get('ENABLE_MKTG_SITE', False)
):
raise Http404
course = get_course_with_access(request.user, course_id, 'see_exists')
......
============================
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.
# -*- coding: utf-8 -*-
"""
Test email scripts.
"""
from smtplib import SMTPDataError, SMTPServerDisconnected
import datetime
import json
import mock
from boto.ses.exceptions import SESIllegalAddressError, SESIdentityNotVerifiedError
from certificates.models import GeneratedCertificate
from django.contrib.auth.models import User
from django.conf import settings
from django.test.utils import override_settings
from django.core import mail
from django.utils.timezone import utc
from django.test import TestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.models import UserProfile
from xmodule.modulestore.tests.django_utils import mixed_store_config
from linkedin.models import LinkedIn
from linkedin.management.commands import linkedin_mailusers as mailusers
from linkedin.management.commands.linkedin_mailusers import MAX_ATTEMPTS
MODULE = 'linkedin.management.commands.linkedin_mailusers.'
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {})
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class MailusersTests(TestCase):
"""
Test mail users command.
"""
def setUp(self):
CourseFactory.create(org='TESTX', number='1', display_name='TEST1',
start=datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc),
end=datetime.datetime(2011, 5, 12, 2, 42, tzinfo=utc))
CourseFactory.create(org='TESTX', number='2', display_name='TEST2',
start=datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc),
end=datetime.datetime(2011, 5, 12, 2, 42, tzinfo=utc))
CourseFactory.create(org='TESTX', number='3', display_name='TEST3',
start=datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc),
end=datetime.datetime(2011, 5, 12, 2, 42, tzinfo=utc))
self.fred = fred = User(username='fred', email='fred@bedrock.gov')
fred.save()
UserProfile(user=fred, name='Fred Flintstone').save()
LinkedIn(user=fred, has_linkedin_account=True).save()
self.barney = barney = User(
username='barney', email='barney@bedrock.gov')
barney.save()
LinkedIn(user=barney, has_linkedin_account=True).save()
UserProfile(user=barney, name='Barney Rubble').save()
self.adam = adam = User(
username='adam', email='adam@adam.gov')
adam.save()
LinkedIn(user=adam, has_linkedin_account=True).save()
UserProfile(user=adam, name='Adam (חיים פּלי)').save()
self.cert1 = cert1 = GeneratedCertificate(
status='downloadable',
user=fred,
course_id='TESTX/1/TEST1',
name='TestX/Intro101',
download_url='http://test.foo/test')
cert1.save()
cert2 = GeneratedCertificate(
status='downloadable',
user=fred,
course_id='TESTX/2/TEST2')
cert2.save()
cert3 = GeneratedCertificate(
status='downloadable',
user=barney,
course_id='TESTX/3/TEST3')
cert3.save()
cert5 = GeneratedCertificate(
status='downloadable',
user=adam,
course_id='TESTX/3/TEST3')
cert5.save()
@mock.patch.dict('django.conf.settings.LINKEDIN_API',
{'EMAIL_WHITELIST': ['barney@bedrock.gov']})
def test_mail_users_with_whitelist(self):
"""
Test emailing users.
"""
fut = mailusers.Command().handle
fut()
self.assertEqual(
json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3'])
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(
mail.outbox[0].to, ['Barney Rubble <barney@bedrock.gov>'])
def test_mail_users_grandfather(self):
"""
Test sending grandfather emails.
"""
fut = mailusers.Command().handle
fut()
self.assertEqual(
json.loads(self.fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2'])
self.assertEqual(
json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3'])
self.assertEqual(
json.loads(self.adam.linkedin.emailed_courses), ['TESTX/3/TEST3'])
self.assertEqual(len(mail.outbox), 3)
self.assertEqual(
mail.outbox[0].to, ['Fred Flintstone <fred@bedrock.gov>'])
self.assertEqual(
mail.outbox[0].subject, 'Fred Flintstone, Add your Achievements to your LinkedIn Profile')
self.assertEqual(
mail.outbox[1].to, ['Barney Rubble <barney@bedrock.gov>'])
self.assertEqual(
mail.outbox[1].subject, 'Barney Rubble, Add your Achievements to your LinkedIn Profile')
self.assertEqual(
mail.outbox[2].subject, u'Adam (חיים פּלי), Add your Achievements to your LinkedIn Profile')
def test_mail_users_grandfather_mock(self):
"""
test that we aren't sending anything when in mock_run mode
"""
fut = mailusers.Command().handle
fut(mock_run=True)
self.assertEqual(
json.loads(self.fred.linkedin.emailed_courses), [])
self.assertEqual(
json.loads(self.barney.linkedin.emailed_courses), [])
self.assertEqual(
json.loads(self.adam.linkedin.emailed_courses), [])
self.assertEqual(len(mail.outbox), 0)
def test_transaction_semantics(self):
fut = mailusers.Command().handle
with mock.patch('linkedin.management.commands.linkedin_mailusers.Command.send_grandfather_email',
return_value=True, side_effect=[True, KeyboardInterrupt]):
try:
fut()
except KeyboardInterrupt:
# expect that this will be uncaught
# check that fred's emailed_courses were updated
self.assertEqual(
json.loads(self.fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2']
)
#check that we did not update barney
self.assertEqual(
json.loads(self.barney.linkedin.emailed_courses), []
)
def test_certificate_url(self):
self.cert1.created_date = datetime.datetime(
2010, 8, 15, 0, 0, tzinfo=utc)
self.cert1.save()
fut = mailusers.Command().certificate_url
self.assertEqual(
fut(self.cert1),
'http://www.linkedin.com/profile/guided?'
'pfCertificationName=TEST1&pfAuthorityName=edX&'
'pfAuthorityId=0000000&'
'pfCertificationUrl=http%3A%2F%2Ftest.foo%2Ftest&pfLicenseNo=TESTX%2F1%2FTEST1&'
'pfCertStartDate=201005&_mSplash=1&'
'trk=eml-prof-edX-1-gf&startTask=CERTIFICATION_NAME&force=true')
def assert_fred_worked(self):
self.assertEqual(json.loads(self.fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2'])
def assert_fred_failed(self):
self.assertEqual(json.loads(self.fred.linkedin.emailed_courses), [])
def assert_barney_worked(self):
self.assertEqual(json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3'])
def assert_barney_failed(self):
self.assertEqual(json.loads(self.barney.linkedin.emailed_courses),[])
def test_single_email_failure(self):
# Test error that will immediately fail a single user, but not the run
with mock.patch('django.core.mail.EmailMessage.send', side_effect=[SESIllegalAddressError, None]):
mailusers.Command().handle()
# Fred should fail with a send error, but we should still run Barney
self.assert_fred_failed()
self.assert_barney_worked()
def test_limited_retry_errors_both_succeed(self):
errors = [
SMTPServerDisconnected, SMTPServerDisconnected, SMTPServerDisconnected, None,
SMTPServerDisconnected, None
]
with mock.patch('django.core.mail.EmailMessage.send', side_effect=errors):
mailusers.Command().handle()
self.assert_fred_worked()
self.assert_barney_worked()
def test_limited_retry_errors_first_fails(self):
errors = (MAX_ATTEMPTS + 1) * [SMTPServerDisconnected] + [None]
with mock.patch('django.core.mail.EmailMessage.send', side_effect=errors):
mailusers.Command().handle()
self.assert_fred_failed()
self.assert_barney_worked()
def test_limited_retry_errors_both_fail(self):
errors = (MAX_ATTEMPTS * 2) * [SMTPServerDisconnected]
with mock.patch('django.core.mail.EmailMessage.send', side_effect=errors):
mailusers.Command().handle()
self.assert_fred_failed()
self.assert_barney_failed()
@mock.patch('time.sleep')
def test_infinite_retry_errors(self, sleep):
def _raise_err():
"""Need this because SMTPDataError takes args"""
raise SMTPDataError("", "")
errors = (MAX_ATTEMPTS * 2) * [_raise_err] + [None, None]
with mock.patch('django.core.mail.EmailMessage.send', side_effect=errors):
mailusers.Command().handle()
self.assert_fred_worked()
self.assert_barney_worked()
def test_total_failure(self):
# If we get this error, we just stop, so neither user gets email.
errors = [SESIdentityNotVerifiedError]
with mock.patch('django.core.mail.EmailMessage.send', side_effect=errors):
mailusers.Command().handle()
self.assert_fred_failed()
self.assert_barney_failed()
# -*- 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']
\ No newline at end of file
"""
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
{% 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>
......@@ -27,7 +27,7 @@ MICROSITE_CONFIGURATION = {
"course_index_overlay_text": "Explore free courses from leading universities.",
"course_index_overlay_logo_file": "openedx/images/header-logo.png",
"homepage_overlay_html": "<h1>Take an Open edX Course</h1>"
},
}
}
if len(MICROSITE_CONFIGURATION.keys()) > 0:
......@@ -36,3 +36,7 @@ if len(MICROSITE_CONFIGURATION.keys()) > 0:
SUBDOMAIN_BRANDING,
VIRTUAL_UNIVERSITIES
)
# pretend we are behind some marketing site, we want to be able to assert that the Microsite config values override
# this global setting
FEATURES['ENABLE_MKTG_SITE'] = True
......@@ -1146,3 +1146,14 @@ GRADES_DOWNLOAD = {
'BUCKET': 'edx-grades',
'ROOT_PATH': '/tmp/edx-s3/grades',
}
##################### LinkedIn #####################
INSTALLED_APPS += ('django_openid_auth',)
############################ LinkedIn Integration #############################
INSTALLED_APPS += ('linkedin',)
LINKEDIN_API = {
'EMAIL_WHITELIST': [],
'COMPANY_ID': '2746406',
}
......@@ -257,7 +257,6 @@ XQUEUE_PORT = 8040
YOUTUBE_PORT = 8031
LTI_PORT = 8765
################### Make tests faster
#http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
......@@ -307,3 +306,6 @@ if len(MICROSITE_CONFIGURATION.keys()) > 0:
VIRTUAL_UNIVERSITIES,
microsites_root=ENV_ROOT / 'edx-platform' / 'test_microsites'
)
######### LinkedIn ########
LINKEDIN_API['COMPANY_ID'] = '0000000'
......@@ -95,7 +95,7 @@ site_status_msg = get_site_status_msg(course_id)
% else:
<ol class="left nav-global">
<%block name="navigation_global_links">
% if settings.FEATURES.get('ENABLE_MKTG_SITE'):
% if MicrositeConfiguration.get_microsite_configuration_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)):
<li class="nav-global-01">
<a href="${marketing_link('HOW_IT_WORKS')}">${_("How it Works")}</a>
</li>
......
Markdown is supported
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