diff --git a/common/djangoapps/edxmako/shortcuts.py b/common/djangoapps/edxmako/shortcuts.py index a52c852727bb..d70e2145ddcc 100644 --- a/common/djangoapps/edxmako/shortcuts.py +++ b/common/djangoapps/edxmako/shortcuts.py @@ -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]) diff --git a/common/djangoapps/util/models.py b/common/djangoapps/util/models.py index 6b2021999398..f2fb241a8228 100644 --- a/common/djangoapps/util/models.py +++ b/common/djangoapps/util/models.py @@ -1 +1,2 @@ # Create your models here. + diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py index aa0f752e7d19..fdbacdec53ac 100644 --- a/lms/djangoapps/branding/views.py +++ b/lms/djangoapps/branding/views.py @@ -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) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 137b52916905..e60b6c5394fc 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -38,6 +38,8 @@ 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') diff --git a/lms/djangoapps/linkedin/README.rst b/lms/djangoapps/linkedin/README.rst new file mode 100644 index 000000000000..2c5490854a61 --- /dev/null +++ b/lms/djangoapps/linkedin/README.rst @@ -0,0 +1,121 @@ +============================ +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 ", + '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. diff --git a/lms/djangoapps/linkedin/__init__.py b/lms/djangoapps/linkedin/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/linkedin/management/__init__.py b/lms/djangoapps/linkedin/management/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/linkedin/management/commands/__init__.py b/lms/djangoapps/linkedin/management/commands/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py new file mode 100644 index 000000000000..4903203be291 --- /dev/null +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -0,0 +1,260 @@ +""" +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), + ('pfAuthorityName', settings.PLATFORM_NAME), + ('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)} + 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 = settings.DEFAULT_FROM_EMAIL + 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 diff --git a/lms/djangoapps/linkedin/management/commands/tests/__init__.py b/lms/djangoapps/linkedin/management/commands/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py new file mode 100644 index 000000000000..333913ead6d5 --- /dev/null +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -0,0 +1,237 @@ +# -*- 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 ']) + + 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 ']) + self.assertEqual( + mail.outbox[0].subject, 'Fred Flintstone, Add your Achievements to your LinkedIn Profile') + self.assertEqual( + mail.outbox[1].to, ['Barney Rubble ']) + 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() diff --git a/lms/djangoapps/linkedin/migrations/0001_initial.py b/lms/djangoapps/linkedin/migrations/0001_initial.py new file mode 100644 index 000000000000..b6ceeef7d740 --- /dev/null +++ b/lms/djangoapps/linkedin/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# -*- 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 diff --git a/lms/djangoapps/linkedin/migrations/__init__.py b/lms/djangoapps/linkedin/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/linkedin/models.py b/lms/djangoapps/linkedin/models.py new file mode 100644 index 000000000000..4d7002a8ce74 --- /dev/null +++ b/lms/djangoapps/linkedin/models.py @@ -0,0 +1,14 @@ +""" +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 diff --git a/lms/djangoapps/linkedin/templates/linkedin_email.html b/lms/djangoapps/linkedin/templates/linkedin_email.html new file mode 100644 index 000000000000..a845875dcd09 --- /dev/null +++ b/lms/djangoapps/linkedin/templates/linkedin_email.html @@ -0,0 +1,25 @@ +{% load i18n %} + + + + + + + +

{% blocktrans with name=student_name %} + Dear {{student_name}}, + {% endblocktrans %}

+ +

{% 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 %}

+ +

+ in + {% blocktrans %}Add to profile{% endblocktrans %} +

+ + diff --git a/lms/envs/cms/microsite_test.py b/lms/envs/cms/microsite_test.py index 511b97e66aa0..700e7c6c166b 100644 --- a/lms/envs/cms/microsite_test.py +++ b/lms/envs/cms/microsite_test.py @@ -27,7 +27,7 @@ "course_index_overlay_text": "Explore free courses from leading universities.", "course_index_overlay_logo_file": "openedx/images/header-logo.png", "homepage_overlay_html": "

Take an Open edX Course

" - }, + } } if len(MICROSITE_CONFIGURATION.keys()) > 0: @@ -36,3 +36,7 @@ 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 diff --git a/lms/envs/common.py b/lms/envs/common.py index 716f11ae2e2b..a3cff7580c9c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1143,3 +1143,14 @@ def enable_microsites(microsite_config_dict, subdomain_branding, virtual_univers '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', +} diff --git a/lms/envs/test.py b/lms/envs/test.py index 9577fa571a4b..5e23b2060718 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -257,7 +257,6 @@ 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 @@ VIRTUAL_UNIVERSITIES, microsites_root=ENV_ROOT / 'edx-platform' / 'test_microsites' ) + +######### LinkedIn ######## +LINKEDIN_API['COMPANY_ID'] = '0000000' diff --git a/lms/static/images/linkedin_add_to_profile.png b/lms/static/images/linkedin_add_to_profile.png new file mode 100644 index 000000000000..d72b680f49eb Binary files /dev/null and b/lms/static/images/linkedin_add_to_profile.png differ diff --git a/lms/templates/linkedin/linkedin_email.html b/lms/templates/linkedin/linkedin_email.html new file mode 100644 index 000000000000..623a2951be79 --- /dev/null +++ b/lms/templates/linkedin/linkedin_email.html @@ -0,0 +1,833 @@ + + + + + +## NAME: 1 COLUMN + + + Share Your edX Success on LinkedIn + + + + + + +
+ + +
+ ## BEGIN TEMPLATE // + + + + + + + + + + +
+ ## BEGIN PREHEADER // + + + + +
+ + + + + +
+ + + + + + +
+ +
 Connect with us on:            
+ +
+ +
+ ## // END PREHEADER +
+ ## BEGIN HEADER // + + + + +
+ + + + + +
+ + + + +
+ + + edX - Connect To A Better Future + + +
+
+ + + + + +
+ + + + + + +
+ + Share your edX success on LinkedIn +
+ +
+ ## // END HEADER +
+ ## BEGIN BODY // + + + + + + + +
+ + + + + +
+ + + + + + +
+ + Good news! We're working with LinkedIn, the world's largest professional network, to make it even easier to showcase your success. That means you can now display your edX certificates on LinkedIn to show what we’ve learned and achieved. Just click “Add to profile” below on each of the certificates you’d like to include on LinkedIn. +
+ +
+ + +%for course_dict in courses_list: + +<% + + course_url = course_dict['course_url'] + course_title = course_dict['course_title'] + course_image_url = course_dict['course_image_url'] + course_org = course_dict['course_org'] + course_end_date = course_dict['course_end_date'] + linkedin_add_url = course_dict['linkedin_add_url'] + +%> + +## Begin table for single class + + + + + + +
+ + + + +
+ + + + + +
+ + + + + + ${course_title} + + + +
+ + + + +
+ ${course_title}
+${course_org}
+Completed ${course_end_date}
+
+ +
Add to profile
+
+
+
+ +## End table for single class cell +%endfor + +## a really complicated hr + + + + + + +
+ + + + +
+ +
+
+ +## text for congrats on your accomplishment + + + + +
+ + + + + +
+ + + + + + +
+ + Congratulations on your accomplishment! Adding this to your profile will help get the word out about your impressive edX achievement. -The edX Team- +
+ +
+ + + + + + + +
+ + + + + + +
+ + +
+ +
+ + + + + +
+ + + + +
+ +
+
+ + + + + +
+ + + + + + +
+ +
+ Stay connected on LinkedIn, +Facebook, Twitter, Google+ and more for news and updates.
+ +
+ +
+ + + + + +
+ + + + +
+ +
+
+ + + + + +
+ + + + + + +
+ +
+           
+ +
+ +
+ ## // END BODY +
+ ## BEGIN FOOTER // + + + + +
+ + + + + +
+ + + + + + +
+ + +
+ Copyright © 2014 edX, All rights reserved.
+
+ Our mailing address is:
+ edX
+ 11 Cambridge Center, Suite 101
+ Cambridge, MA, USA 02142
+
+
+ +
+ ## // END FOOTER +
+ ## // END TEMPLATE +
+ + + diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 9f4f5f04eeaa..b0da8aaa1c77 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -95,7 +95,7 @@

${course.display_org_with_default | h}: ${cour % else: