From cc53aab4ec3618bce7874449468854250d135f0c Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 9 Dec 2013 14:53:18 -0500 Subject: [PATCH 01/48] Initial app and model for LinkedIn integration. --- lms/djangoapps/linkedin/__init__.py | 0 lms/djangoapps/linkedin/management/__init__.py | 0 lms/djangoapps/linkedin/management/commands/__init__.py | 0 lms/djangoapps/linkedin/management/commands/findusers.py | 8 ++++++++ lms/djangoapps/linkedin/models.py | 7 +++++++ lms/envs/dev.py | 3 +++ 6 files changed, 18 insertions(+) create mode 100644 lms/djangoapps/linkedin/__init__.py create mode 100644 lms/djangoapps/linkedin/management/__init__.py create mode 100644 lms/djangoapps/linkedin/management/commands/__init__.py create mode 100644 lms/djangoapps/linkedin/management/commands/findusers.py create mode 100644 lms/djangoapps/linkedin/models.py 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/findusers.py b/lms/djangoapps/linkedin/management/commands/findusers.py new file mode 100644 index 000000000000..fe5a572e0daa --- /dev/null +++ b/lms/djangoapps/linkedin/management/commands/findusers.py @@ -0,0 +1,8 @@ +from django.core.management.base import BaseCommand, CommandError + +class Command(BaseCommand): + args = '' + help = 'Checks LinkedIn for students that are on LinkedIn' + + def handle(self, *args, **options): + print "Hello World!" diff --git a/lms/djangoapps/linkedin/models.py b/lms/djangoapps/linkedin/models.py new file mode 100644 index 000000000000..738d5ec7093a --- /dev/null +++ b/lms/djangoapps/linkedin/models.py @@ -0,0 +1,7 @@ +from django.contrib.auth.models import User +from django.db import models + + +class LinkedIn(models.Model): + user = models.OneToOneField(User, primary_key=True) + has_linkedin_account = models.NullBooleanField(default=None) diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 6b4446893bd1..e0801a4173a9 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -293,3 +293,6 @@ from .private import * # pylint: disable=F0401 except ImportError: pass + +####################### Linkedin ########################### +INSTALLED_APPS += ('linkedin',) From 64887c6870546fd78082459dd44065f055a8fe54 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Wed, 11 Dec 2013 12:29:09 -0500 Subject: [PATCH 02/48] Flesh out findusers script, except for LinkedIn API call, with tests. --- .../linkedin/management/commands/findusers.py | 88 ++++++++++- .../management/commands/tests/__init__.py | 0 .../commands/tests/test_findusers.py | 143 ++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/linkedin/management/commands/tests/__init__.py create mode 100644 lms/djangoapps/linkedin/management/commands/tests/test_findusers.py diff --git a/lms/djangoapps/linkedin/management/commands/findusers.py b/lms/djangoapps/linkedin/management/commands/findusers.py index fe5a572e0daa..9ea76623666c 100644 --- a/lms/djangoapps/linkedin/management/commands/findusers.py +++ b/lms/djangoapps/linkedin/management/commands/findusers.py @@ -1,8 +1,94 @@ +import datetime +import pytz +import time + +from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from optparse import make_option + + +FRIDAY = 4 + +def get_call_limits(): + """ + Returns a tuple of: (max_checks, checks_per_call, time_between_calls) + + Here are the parameters provided by LinkedIn: + + Please note: in order to ensure a successful call, please run the calls + between Friday 6pm PST and Monday 5am PST. + + During the week, calls are limited to very low volume (500 profiles/day) + and must be run after 6pm and before 5am. This should only be used to do + subsequent trigger emails. Please contact the developer support alias for + more information. + + Use 80 emails per API call and 1 call per second. + """ + now = timezone.now().astimezone(pytz.timezone('US/Pacific')) + lastfriday = now + while lastfriday.weekday() != FRIDAY: + lastfriday -= datetime.timedelta(days=1) + safeharbor_begin = lastfriday.replace(hour=18, minute=0) + safeharbor_end = safeharbor_begin + datetime.timedelta(days=2, hours=11) + if safeharbor_begin < now < safeharbor_end: + return -1, 80, 1 + elif now.hour >= 18 or now.hour < 5: + return 500, 80, 1 + else: + return 0, 0, 0 + class Command(BaseCommand): args = '' help = 'Checks LinkedIn for students that are on LinkedIn' + option_list = BaseCommand.option_list + ( + make_option('--recheck', + action='store_true', + dest='recheck', + default=False, + help='Check users that have been checked in the past to see if ' + 'they have joined or left LinkedIn since the last check'), + ) def handle(self, *args, **options): - print "Hello World!" + api = LinkedinAPI() + recheck = options.pop('recheck', False) + max_checks, checks_per_call, time_between_calls = get_call_limits() + if not max_checks: + raise CommandError("No checks allowed during this time.") + + check_users = [] + for user in User.objects.all(): + checked = (hasattr(user, 'linkedin') and + user.linkedin.has_linkedin_account is not None) + if recheck or not checked: + check_users.append(user) + + if max_checks != -1 and len(check_users) > max_checks: + self.stderr.write( + "WARNING: limited to checking only %d users today." % + max_checks) + check_users = check_users[:max_checks] + batches = [check_users[i:i + checks_per_call] + for i in xrange(0, len(check_users), checks_per_call)] + + def do_batch(batch): + emails = [u.email for u in batch] + for user, has_account in zip(batch, api.batch(emails)): + user.linkedin.has_linkedin_account = has_account + + if batches: + do_batch(batches.pop(0)) + for batch in batches: + time.sleep(time_between_calls) + do_batch(batch) + + +class LinkedinAPI(object): + + def batch(self, emails): + pass + 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_findusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py new file mode 100644 index 000000000000..d016b67b6f7d --- /dev/null +++ b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py @@ -0,0 +1,143 @@ +import datetime +import mock +import pytz +import StringIO +import unittest + + +from linkedin.management.commands import findusers + + +class FindUsersTests(unittest.TestCase): + + @mock.patch('linkedin.management.commands.findusers.timezone') + def test_get_call_limits_in_safe_harbor(self, timezone): + fut = findusers.get_call_limits + tz = pytz.timezone('US/Eastern') + timezone.now.return_value = datetime.datetime( + 2013, 12, 14, 0, 0, tzinfo=tz) + self.assertEqual(fut(), (-1, 80, 1)) + timezone.now.return_value = datetime.datetime( + 2013, 12, 13, 21, 1, tzinfo=tz) + self.assertEqual(fut(), (-1, 80, 1)) + timezone.now.return_value = datetime.datetime( + 2013, 12, 15, 7, 59, tzinfo=tz) + self.assertEqual(fut(), (-1, 80, 1)) + + @mock.patch('linkedin.management.commands.findusers.timezone') + def test_get_call_limits_in_business_hours(self, timezone): + fut = findusers.get_call_limits + tz = pytz.timezone('US/Eastern') + timezone.now.return_value = datetime.datetime( + 2013, 12, 11, 11, 3, tzinfo=tz) + self.assertEqual(fut(), (0, 0, 0)) + timezone.now.return_value = datetime.datetime( + 2013, 12, 13, 20, 59, tzinfo=tz) + self.assertEqual(fut(), (0, 0, 0)) + timezone.now.return_value = datetime.datetime( + 2013, 12, 16, 8, 1, tzinfo=tz) + self.assertEqual(fut(), (0, 0, 0)) + + @mock.patch('linkedin.management.commands.findusers.timezone') + def test_get_call_limits_on_weeknights(self, timezone): + fut = findusers.get_call_limits + tz = pytz.timezone('US/Eastern') + timezone.now.return_value = datetime.datetime( + 2013, 12, 11, 21, 3, tzinfo=tz) + self.assertEqual(fut(), (500, 80, 1)) + timezone.now.return_value = datetime.datetime( + 2013, 12, 11, 7, 59, tzinfo=tz) + self.assertEqual(fut(), (500, 80, 1)) + + @mock.patch('linkedin.management.commands.findusers.time') + @mock.patch('linkedin.management.commands.findusers.User') + @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') + @mock.patch('linkedin.management.commands.findusers.get_call_limits') + def test_command_success_recheck_no_limits( + self, get_call_limits, API, User, time): + + fut = findusers.Command().handle + get_call_limits.return_value = (-1, 6, 42) + api = API.return_value + users = [mock.Mock(email=i) for i in xrange(10)] + User.objects.all.return_value = users + def dummy_batch(emails): + return [email % 2 == 0 for email in emails] + api.batch = dummy_batch + fut(recheck=True) + time.sleep.assert_called_once_with(42) + self.assertEqual([u.linkedin.has_linkedin_account for u in users], + [i % 2 == 0 for i in xrange(10)]) + + @mock.patch('linkedin.management.commands.findusers.time') + @mock.patch('linkedin.management.commands.findusers.User') + @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') + @mock.patch('linkedin.management.commands.findusers.get_call_limits') + def test_command_success_no_recheck_no_limits( + self, get_call_limits, API, User, time): + + fut = findusers.Command().handle + get_call_limits.return_value = (-1, 6, 42) + api = API.return_value + users = [mock.Mock(email=i) for i in xrange(10)] + for user in users[:6]: + user.linkedin.has_linkedin_account = user.email % 2 == 0 + for user in users[6:]: + user.linkedin.has_linkedin_account = None + User.objects.all.return_value = users + def dummy_batch(emails): + self.assertEqual(len(emails), 4) + return [email % 2 == 0 for email in emails] + api.batch = dummy_batch + fut() + time.sleep.assert_not_called() + self.assertEqual([u.linkedin.has_linkedin_account for u in users], + [i % 2 == 0 for i in xrange(10)]) + + @mock.patch('linkedin.management.commands.findusers.time') + @mock.patch('linkedin.management.commands.findusers.User') + @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') + @mock.patch('linkedin.management.commands.findusers.get_call_limits') + def test_command_success_no_recheck_no_users( + self, get_call_limits, API, User, time): + + fut = findusers.Command().handle + get_call_limits.return_value = (-1, 6, 42) + api = API.return_value + users = [mock.Mock(email=i) for i in xrange(10)] + for user in users: + user.linkedin.has_linkedin_account = user.email % 2 == 0 + User.objects.all.return_value = users + def dummy_batch(emails): + self.assertTrue(False) # shouldn't be called + api.batch = dummy_batch + fut() + time.sleep.assert_not_called() + self.assertEqual([u.linkedin.has_linkedin_account for u in users], + [i % 2 == 0 for i in xrange(10)]) + + @mock.patch('linkedin.management.commands.findusers.time') + @mock.patch('linkedin.management.commands.findusers.User') + @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') + @mock.patch('linkedin.management.commands.findusers.get_call_limits') + def test_command_success_recheck_with_limit( + self, get_call_limits, API, User, time): + + command = findusers.Command() + command.stderr = StringIO.StringIO() + fut = command.handle + get_call_limits.return_value = (9, 6, 42) + api = API.return_value + users = [mock.Mock(email=i) for i in xrange(10)] + for user in users: + user.linkedin.has_linkedin_account = None + User.objects.all.return_value = users + def dummy_batch(emails): + return [email % 2 == 0 for email in emails] + api.batch = dummy_batch + fut() + time.sleep.assert_called_once_with(42) + self.assertEqual([u.linkedin.has_linkedin_account for u in users[:9]], + [i % 2 == 0 for i in xrange(9)]) + self.assertEqual(users[9].linkedin.has_linkedin_account, None) + self.assertTrue(command.stderr.getvalue().startswith("WARNING")) From a5d1cb359218b9c6882ea40188d0457d14d983e7 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 12 Dec 2013 11:22:21 -0500 Subject: [PATCH 03/48] pep8 and pylint --- .../linkedin/management/commands/findusers.py | 27 ++++- .../commands/tests/test_findusers.py | 98 ++++++++++++------- lms/djangoapps/linkedin/models.py | 6 ++ 3 files changed, 93 insertions(+), 38 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/findusers.py b/lms/djangoapps/linkedin/management/commands/findusers.py index 9ea76623666c..86a67606e021 100644 --- a/lms/djangoapps/linkedin/management/commands/findusers.py +++ b/lms/djangoapps/linkedin/management/commands/findusers.py @@ -1,3 +1,7 @@ +""" +Provides a command to use with Django's `manage.py` that uses LinkedIn's API to +find edX users that are also users on LinkedIn. +""" import datetime import pytz import time @@ -8,9 +12,9 @@ from optparse import make_option - FRIDAY = 4 + def get_call_limits(): """ Returns a tuple of: (max_checks, checks_per_call, time_between_calls) @@ -42,18 +46,25 @@ def get_call_limits(): class Command(BaseCommand): + """ + Provides a command to use with Django's `manage.py` that uses LinkedIn's + API to find edX users that are also users on LinkedIn. + """ args = '' help = 'Checks LinkedIn for students that are on LinkedIn' option_list = BaseCommand.option_list + ( - make_option('--recheck', + make_option( + '--recheck', action='store_true', dest='recheck', default=False, help='Check users that have been checked in the past to see if ' - 'they have joined or left LinkedIn since the last check'), - ) + 'they have joined or left LinkedIn since the last check'),) def handle(self, *args, **options): + """ + Check users. + """ api = LinkedinAPI() recheck = options.pop('recheck', False) max_checks, checks_per_call, time_between_calls = get_call_limits() @@ -76,6 +87,7 @@ def handle(self, *args, **options): for i in xrange(0, len(check_users), checks_per_call)] def do_batch(batch): + "Process a batch of users." emails = [u.email for u in batch] for user, has_account in zip(batch, api.batch(emails)): user.linkedin.has_linkedin_account = has_account @@ -88,7 +100,12 @@ def do_batch(batch): class LinkedinAPI(object): + """ + Encapsulates the LinkedIn API. + """ def batch(self, emails): + """ + Get the LinkedIn status for a batch of emails. + """ pass - diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py index d016b67b6f7d..a6e01ec77218 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py @@ -1,3 +1,6 @@ +""" +Tests for the findusers script. +""" import datetime import mock import pytz @@ -9,59 +12,76 @@ class FindUsersTests(unittest.TestCase): + """ + Tests for the findusers script. + """ @mock.patch('linkedin.management.commands.findusers.timezone') def test_get_call_limits_in_safe_harbor(self, timezone): + """ + We should be able to perform unlimited API calls during "safe harbor". + """ fut = findusers.get_call_limits - tz = pytz.timezone('US/Eastern') + tzinfo = pytz.timezone('US/Eastern') timezone.now.return_value = datetime.datetime( - 2013, 12, 14, 0, 0, tzinfo=tz) + 2013, 12, 14, 0, 0, tzinfo=tzinfo) self.assertEqual(fut(), (-1, 80, 1)) timezone.now.return_value = datetime.datetime( - 2013, 12, 13, 21, 1, tzinfo=tz) + 2013, 12, 13, 21, 1, tzinfo=tzinfo) self.assertEqual(fut(), (-1, 80, 1)) timezone.now.return_value = datetime.datetime( - 2013, 12, 15, 7, 59, tzinfo=tz) + 2013, 12, 15, 7, 59, tzinfo=tzinfo) self.assertEqual(fut(), (-1, 80, 1)) @mock.patch('linkedin.management.commands.findusers.timezone') def test_get_call_limits_in_business_hours(self, timezone): + """ + During business hours we shouldn't be able to make any API calls. + """ fut = findusers.get_call_limits - tz = pytz.timezone('US/Eastern') + tzinfo = pytz.timezone('US/Eastern') timezone.now.return_value = datetime.datetime( - 2013, 12, 11, 11, 3, tzinfo=tz) + 2013, 12, 11, 11, 3, tzinfo=tzinfo) self.assertEqual(fut(), (0, 0, 0)) timezone.now.return_value = datetime.datetime( - 2013, 12, 13, 20, 59, tzinfo=tz) + 2013, 12, 13, 20, 59, tzinfo=tzinfo) self.assertEqual(fut(), (0, 0, 0)) timezone.now.return_value = datetime.datetime( - 2013, 12, 16, 8, 1, tzinfo=tz) + 2013, 12, 16, 8, 1, tzinfo=tzinfo) self.assertEqual(fut(), (0, 0, 0)) @mock.patch('linkedin.management.commands.findusers.timezone') def test_get_call_limits_on_weeknights(self, timezone): + """ + On weeknights outside of "safe harbor" we can only make limited API + calls. + """ fut = findusers.get_call_limits - tz = pytz.timezone('US/Eastern') + tzinfo = pytz.timezone('US/Eastern') timezone.now.return_value = datetime.datetime( - 2013, 12, 11, 21, 3, tzinfo=tz) + 2013, 12, 11, 21, 3, tzinfo=tzinfo) self.assertEqual(fut(), (500, 80, 1)) timezone.now.return_value = datetime.datetime( - 2013, 12, 11, 7, 59, tzinfo=tz) + 2013, 12, 11, 7, 59, tzinfo=tzinfo) self.assertEqual(fut(), (500, 80, 1)) @mock.patch('linkedin.management.commands.findusers.time') @mock.patch('linkedin.management.commands.findusers.User') @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') @mock.patch('linkedin.management.commands.findusers.get_call_limits') - def test_command_success_recheck_no_limits( - self, get_call_limits, API, User, time): - + def test_command_success_recheck_no_limits(self, get_call_limits, apicls, + usercls, time): + """ + Test rechecking all users with no API limits. + """ fut = findusers.Command().handle get_call_limits.return_value = (-1, 6, 42) - api = API.return_value + api = apicls.return_value users = [mock.Mock(email=i) for i in xrange(10)] - User.objects.all.return_value = users + usercls.objects.all.return_value = users + def dummy_batch(emails): + "Mock LinkedIn API." return [email % 2 == 0 for email in emails] api.batch = dummy_batch fut(recheck=True) @@ -73,19 +93,23 @@ def dummy_batch(emails): @mock.patch('linkedin.management.commands.findusers.User') @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') @mock.patch('linkedin.management.commands.findusers.get_call_limits') - def test_command_success_no_recheck_no_limits( - self, get_call_limits, API, User, time): - + def test_command_success_no_recheck_no_limits(self, get_call_limits, apicls, + usercls, time): + """ + Test checking only unchecked users, with no API limits. + """ fut = findusers.Command().handle get_call_limits.return_value = (-1, 6, 42) - api = API.return_value + api = apicls.return_value users = [mock.Mock(email=i) for i in xrange(10)] for user in users[:6]: user.linkedin.has_linkedin_account = user.email % 2 == 0 for user in users[6:]: user.linkedin.has_linkedin_account = None - User.objects.all.return_value = users + usercls.objects.all.return_value = users + def dummy_batch(emails): + "Mock LinkedIn API." self.assertEqual(len(emails), 4) return [email % 2 == 0 for email in emails] api.batch = dummy_batch @@ -98,18 +122,22 @@ def dummy_batch(emails): @mock.patch('linkedin.management.commands.findusers.User') @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') @mock.patch('linkedin.management.commands.findusers.get_call_limits') - def test_command_success_no_recheck_no_users( - self, get_call_limits, API, User, time): - + def test_command_success_no_recheck_no_users(self, get_call_limits, apicls, + usercls, time): + """ + Test no users to check. + """ fut = findusers.Command().handle get_call_limits.return_value = (-1, 6, 42) - api = API.return_value + api = apicls.return_value users = [mock.Mock(email=i) for i in xrange(10)] for user in users: user.linkedin.has_linkedin_account = user.email % 2 == 0 - User.objects.all.return_value = users - def dummy_batch(emails): - self.assertTrue(False) # shouldn't be called + usercls.objects.all.return_value = users + + def dummy_batch(_): + "Mock LinkedIn API." + self.assertTrue(False) # shouldn't be called api.batch = dummy_batch fut() time.sleep.assert_not_called() @@ -120,19 +148,23 @@ def dummy_batch(emails): @mock.patch('linkedin.management.commands.findusers.User') @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') @mock.patch('linkedin.management.commands.findusers.get_call_limits') - def test_command_success_recheck_with_limit( - self, get_call_limits, API, User, time): - + def test_command_success_recheck_with_limit(self, get_call_limits, apicls, + usercls, time): + """ + Test recheck all users with API limit. + """ command = findusers.Command() command.stderr = StringIO.StringIO() fut = command.handle get_call_limits.return_value = (9, 6, 42) - api = API.return_value + api = apicls.return_value users = [mock.Mock(email=i) for i in xrange(10)] for user in users: user.linkedin.has_linkedin_account = None - User.objects.all.return_value = users + usercls.objects.all.return_value = users + def dummy_batch(emails): + "Mock LinkedIn API." return [email % 2 == 0 for email in emails] api.batch = dummy_batch fut() diff --git a/lms/djangoapps/linkedin/models.py b/lms/djangoapps/linkedin/models.py index 738d5ec7093a..7beea5f6d904 100644 --- a/lms/djangoapps/linkedin/models.py +++ b/lms/djangoapps/linkedin/models.py @@ -1,7 +1,13 @@ +""" +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) From 883563d8dfa0c6c0fd16e27b7e41485269588b8e Mon Sep 17 00:00:00 2001 From: vagrant Date: Thu, 12 Dec 2013 16:35:20 +0000 Subject: [PATCH 04/48] Test max_calls == 0 --- .../management/commands/tests/test_findusers.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py index a6e01ec77218..da779fd8a3bf 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py @@ -173,3 +173,14 @@ def dummy_batch(emails): [i % 2 == 0 for i in xrange(9)]) self.assertEqual(users[9].linkedin.has_linkedin_account, None) self.assertTrue(command.stderr.getvalue().startswith("WARNING")) + + @mock.patch('linkedin.management.commands.findusers.get_call_limits') + def test_command_no_api_calls(self, get_call_limits): + """ + Test rechecking all users with no API limits. + """ + from django.core.management.base import CommandError + fut = findusers.Command().handle + get_call_limits.return_value = (0, 0, 0) + with self.assertRaises(CommandError): + fut(recheck=True) From e34d131f709bd1f960c8e1f5e81a036fb8f4cbd8 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 12 Dec 2013 15:02:29 -0500 Subject: [PATCH 05/48] Skeleton for emails script. --- .../linkedin/management/commands/mailusers.py | 61 +++++++++++++++++++ lms/djangoapps/linkedin/models.py | 1 + 2 files changed, 62 insertions(+) create mode 100644 lms/djangoapps/linkedin/management/commands/mailusers.py diff --git a/lms/djangoapps/linkedin/management/commands/mailusers.py b/lms/djangoapps/linkedin/management/commands/mailusers.py new file mode 100644 index 000000000000..83b03e4114bb --- /dev/null +++ b/lms/djangoapps/linkedin/management/commands/mailusers.py @@ -0,0 +1,61 @@ +""" +Send emails to users inviting them to add their course certificates to their +LinkedIn profiles. +""" + +from itertools import imap + +from django.core.management.base import BaseCommand +from optparse import make_option + +from certificates.models import GeneratedCertificate +from ...models import LinkedIn + + +class Command(BaseCommand): + 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( + '--grandfather', + action='store_true', + dest='grandfather', + default=False, + help="Creates aggregate invitations for all certificates a user " + "has earned to date and sends a 'grandfather' email. This is " + "intended to be used when the feature is launched to invite " + "all users that have earned certificates to date to add their " + "certificates. Afterwards the default, one email per " + "certificate mail form will be used."),) + + def handle(self, *args, **options): + grandfather = options.get('grandfather', False) + accounts = LinkedIn.objects.filter(has_linkedin_account=True) + for user in imap(lambda account: account.user, accounts): # lazy + certificates = GeneratedCertificate.objects.filter(user=user) + certificates = certificates.filter(status='downloadable') + if not certificates: + continue + if grandfather: + send_grandfather_email(user, certificates) + else: + for certificate in certificates: + send_email(user, certificate) + + +def send_grandfather_email(user, certificates): + """ + Send the 'grandfathered' email informing historical students that they may + now post their certificates on their LinkedIn profiles. + """ + print "GRANDFATHER: ", user, certificates + + +def send_email(user, certificate): + """ + Email a user that recently earned a certificate, inviting them to post their + certificate on their LinkedIn profile. + """ + print "EMAIL: ", user, certificate diff --git a/lms/djangoapps/linkedin/models.py b/lms/djangoapps/linkedin/models.py index 7beea5f6d904..4d7002a8ce74 100644 --- a/lms/djangoapps/linkedin/models.py +++ b/lms/djangoapps/linkedin/models.py @@ -11,3 +11,4 @@ class LinkedIn(models.Model): """ user = models.OneToOneField(User, primary_key=True) has_linkedin_account = models.NullBooleanField(default=None) + emailed_courses = models.TextField(default="[]") # JSON list of course ids From 2d5c4dae524bacded0ac7a56ab18dd74402dcc4f Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 12 Dec 2013 17:11:32 -0500 Subject: [PATCH 06/48] Test coverage. --- .../linkedin/management/commands/mailusers.py | 4 ++ .../commands/tests/test_mailusers.py | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py diff --git a/lms/djangoapps/linkedin/management/commands/mailusers.py b/lms/djangoapps/linkedin/management/commands/mailusers.py index 83b03e4114bb..15043afae18c 100644 --- a/lms/djangoapps/linkedin/management/commands/mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/mailusers.py @@ -13,6 +13,10 @@ 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 ' 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..4c349236cb56 --- /dev/null +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -0,0 +1,39 @@ +""" +Test email scripts. +""" +import mock +import unittest + +from linkedin.management.commands import mailusers + + +class MailusersTests(unittest.TestCase): + """ + Test mail users command. + """ + + @mock.patch('linkedin.management.commands.mailusers.send_email') + @mock.patch('linkedin.management.commands.mailusers.GeneratedCertificate') + @mock.patch('linkedin.management.commands.mailusers.LinkedIn') + def test_mail_users(self, linkedin, certificates, send_email): + """ + Test "happy path" for emailing users. + """ + fut = mailusers.Command().handle + fred = mock.Mock(user=mock.Mock(certificates=[1, 2])) + barney = mock.Mock(user=mock.Mock(certificates=[3])) + linkedin.objects.filter.return_value = [fred, barney] + + def filter_user(user): + "Mock querying the database." + queryset = mock.Mock() + queryset.filter.return_value = user.certificates + return queryset + + certificates.objects.filter = filter_user + fut() + self.assertEqual( + send_email.call_args_list, + [((fred.user, 1),), + ((fred.user, 2),), + ((barney.user, 3),)]) From 2468c99c521ab92bf37754b1b9239125fac08933 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 13 Dec 2013 10:04:01 -0500 Subject: [PATCH 07/48] Test grandfather path. --- .../commands/tests/test_mailusers.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index 4c349236cb56..a3be0da932f5 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -37,3 +37,28 @@ def filter_user(user): [((fred.user, 1),), ((fred.user, 2),), ((barney.user, 3),)]) + + @mock.patch('linkedin.management.commands.mailusers.send_grandfather_email') + @mock.patch('linkedin.management.commands.mailusers.GeneratedCertificate') + @mock.patch('linkedin.management.commands.mailusers.LinkedIn') + def test_mail_users_grandfather(self, linkedin, certificates, send_email): + """ + Test "happy path" for sending grandfather emails. + """ + fut = mailusers.Command().handle + fred = mock.Mock(user=mock.Mock(certificates=[1, 2])) + barney = mock.Mock(user=mock.Mock(certificates=[3])) + linkedin.objects.filter.return_value = [fred, barney] + + def filter_user(user): + "Mock querying the database." + queryset = mock.Mock() + queryset.filter.return_value = user.certificates + return queryset + + certificates.objects.filter = filter_user + fut(grandfather=True) + self.assertEqual( + send_email.call_args_list, + [((fred.user, [1, 2]),), + ((barney.user, [3]),)]) From fc17ed26d4f518c78db54ba967c0ae9c35f4724e Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 13 Dec 2013 10:40:01 -0500 Subject: [PATCH 08/48] Properly handle already emailed courses. --- .../linkedin/management/commands/mailusers.py | 11 ++- .../commands/tests/test_mailusers.py | 77 ++++++++++++++++--- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/mailusers.py b/lms/djangoapps/linkedin/management/commands/mailusers.py index 15043afae18c..aca7e35bfce3 100644 --- a/lms/djangoapps/linkedin/management/commands/mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/mailusers.py @@ -3,7 +3,7 @@ LinkedIn profiles. """ -from itertools import imap +import json from django.core.management.base import BaseCommand from optparse import make_option @@ -37,16 +37,23 @@ class Command(BaseCommand): def handle(self, *args, **options): grandfather = options.get('grandfather', False) accounts = LinkedIn.objects.filter(has_linkedin_account=True) - for user in imap(lambda account: account.user, accounts): # lazy + for account in accounts: + emailed = json.loads(account.emailed_courses) + user = account.user certificates = GeneratedCertificate.objects.filter(user=user) certificates = certificates.filter(status='downloadable') + certificates = [cert for cert in certificates + if cert.course_id not in emailed] if not certificates: continue if grandfather: send_grandfather_email(user, certificates) + emailed.extend([cert.course_id for cert in certificates]) else: for certificate in certificates: send_email(user, certificate) + emailed.append(certificate.course_id) + account.emailed_courses = json.dumps(emailed) def send_grandfather_email(user, certificates): diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index a3be0da932f5..e821de1ffb50 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -1,6 +1,7 @@ """ Test email scripts. """ +import json import mock import unittest @@ -17,11 +18,18 @@ class MailusersTests(unittest.TestCase): @mock.patch('linkedin.management.commands.mailusers.LinkedIn') def test_mail_users(self, linkedin, certificates, send_email): """ - Test "happy path" for emailing users. + Test emailing users. """ fut = mailusers.Command().handle - fred = mock.Mock(user=mock.Mock(certificates=[1, 2])) - barney = mock.Mock(user=mock.Mock(certificates=[3])) + cert1 = mock.Mock(course_id=1) + cert2 = mock.Mock(course_id=2) + cert3 = mock.Mock(course_id=3) + fred = mock.Mock( + emailed_courses="[]", + user=mock.Mock(certificates=[cert1, cert2])) + barney = mock.Mock( + emailed_courses="[]", + user=mock.Mock(certificates=[cert3])) linkedin.objects.filter.return_value = [fred, barney] def filter_user(user): @@ -34,20 +42,29 @@ def filter_user(user): fut() self.assertEqual( send_email.call_args_list, - [((fred.user, 1),), - ((fred.user, 2),), - ((barney.user, 3),)]) + [((fred.user, cert1),), + ((fred.user, cert2),), + ((barney.user, cert3),)]) + self.assertEqual(json.loads(fred.emailed_courses), [1, 2]) + self.assertEqual(json.loads(barney.emailed_courses), [3]) @mock.patch('linkedin.management.commands.mailusers.send_grandfather_email') @mock.patch('linkedin.management.commands.mailusers.GeneratedCertificate') @mock.patch('linkedin.management.commands.mailusers.LinkedIn') def test_mail_users_grandfather(self, linkedin, certificates, send_email): """ - Test "happy path" for sending grandfather emails. + Test sending grandfather emails. """ fut = mailusers.Command().handle - fred = mock.Mock(user=mock.Mock(certificates=[1, 2])) - barney = mock.Mock(user=mock.Mock(certificates=[3])) + cert1 = mock.Mock(course_id=1) + cert2 = mock.Mock(course_id=2) + cert3 = mock.Mock(course_id=3) + fred = mock.Mock( + emailed_courses="[]", + user=mock.Mock(certificates=[cert1, cert2])) + barney = mock.Mock( + emailed_courses="[]", + user=mock.Mock(certificates=[cert3])) linkedin.objects.filter.return_value = [fred, barney] def filter_user(user): @@ -60,5 +77,43 @@ def filter_user(user): fut(grandfather=True) self.assertEqual( send_email.call_args_list, - [((fred.user, [1, 2]),), - ((barney.user, [3]),)]) + [((fred.user, [cert1, cert2]),), + ((barney.user, [cert3]),)]) + self.assertEqual(json.loads(fred.emailed_courses), [1, 2]) + self.assertEqual(json.loads(barney.emailed_courses), [3]) + + @mock.patch('linkedin.management.commands.mailusers.send_email') + @mock.patch('linkedin.management.commands.mailusers.GeneratedCertificate') + @mock.patch('linkedin.management.commands.mailusers.LinkedIn') + def test_mail_users_only_new_courses(self, linkedin, certificates, + send_email): + """ + Test emailing users, making sure they are only emailed about new + certificates. + """ + fut = mailusers.Command().handle + cert1 = mock.Mock(course_id=1) + cert2 = mock.Mock(course_id=2) + cert3 = mock.Mock(course_id=3) + fred = mock.Mock( + emailed_courses="[1]", + user=mock.Mock(certificates=[cert1, cert2])) + barney = mock.Mock( + emailed_courses="[]", + user=mock.Mock(certificates=[cert3])) + linkedin.objects.filter.return_value = [fred, barney] + + def filter_user(user): + "Mock querying the database." + queryset = mock.Mock() + queryset.filter.return_value = user.certificates + return queryset + + certificates.objects.filter = filter_user + fut() + self.assertEqual( + send_email.call_args_list, + [((fred.user, cert2),), + ((barney.user, cert3),)]) + self.assertEqual(json.loads(fred.emailed_courses), [1, 2]) + self.assertEqual(json.loads(barney.emailed_courses), [3]) From b2db05fc89b18b2849cd6e04c41af2841c88ec95 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 13 Dec 2013 14:45:15 -0500 Subject: [PATCH 09/48] Initial stab at placeholder email template. --- linkedin-login.py | 36 +++++++++++++++++++ .../linkedin/management/commands/mailusers.py | 11 +++++- .../linkedin/templates/linkedin_email.html | 25 +++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 linkedin-login.py create mode 100644 lms/djangoapps/linkedin/templates/linkedin_email.html diff --git a/linkedin-login.py b/linkedin-login.py new file mode 100644 index 000000000000..fcd90c39b366 --- /dev/null +++ b/linkedin-login.py @@ -0,0 +1,36 @@ +import urllib2 +import uuid + +CLIENT_ID = "77zv1vf44fo92m" +CLIENT_SECRET = "1M3wZUNA2aYHldbv" +REDIRECT_URI = "http://www.edx.org" +STATE = uuid.uuid4() +SCOPE = "%20".join([ + "r_basicprofile", + "r_fullprofile", + "r_emailaddress", + "r_network", + "r_contactinfo", + "rw_nus", + "rw_company_admin", + "rw_groups", + "w_messages"]) + +print "Go here:" +print ("https://www.linkedin.com/uas/oauth2/authorization?response_type=code" + "&client_id=%s&state=%s&redirect_uri=%s&scope=%s" % + (CLIENT_ID, STATE, REDIRECT_URI, SCOPE)) + +print "Enter authcode: ", +code = raw_input() + +url = ("https://www.linkedin.com/uas/oauth2/accessToken" + "?grant_type=authorization_code" + "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( + code, REDIRECT_URI, CLIENT_ID, CLIENT_SECRET)) +try: + print urllib2.urlopen(url).read() +except urllib2.HTTPError, e: + print "!!ERROR!!" + print e + print e.read() diff --git a/lms/djangoapps/linkedin/management/commands/mailusers.py b/lms/djangoapps/linkedin/management/commands/mailusers.py index aca7e35bfce3..b046bd0445df 100644 --- a/lms/djangoapps/linkedin/management/commands/mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/mailusers.py @@ -5,7 +5,10 @@ import json +from courseware.courses import get_course_by_id from django.core.management.base import BaseCommand +from django.template import Context +from django.template.loader import get_template from optparse import make_option from certificates.models import GeneratedCertificate @@ -69,4 +72,10 @@ def send_email(user, certificate): Email a user that recently earned a certificate, inviting them to post their certificate on their LinkedIn profile. """ - print "EMAIL: ", user, certificate + template = get_template("linkedin_email.html") + course = get_course_by_id(certificate.course_id) + context = Context({ + 'student_name': user.profile.name, + 'course_name': 'XXX', + 'url': '#'}) + print template.render(context) diff --git a/lms/djangoapps/linkedin/templates/linkedin_email.html b/lms/djangoapps/linkedin/templates/linkedin_email.html new file mode 100644 index 000000000000..54db13d8949c --- /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 %} +

+ + From 411d39deea0ae923e5fb6767cfc6aa14f8be84de Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 13 Dec 2013 16:04:09 -0500 Subject: [PATCH 10/48] Script to log in to LinkedIn API. --- .../linkedin/management/commands/login.py | 72 +++++++++++++++++++ lms/djangoapps/linkedin/models.py | 9 +++ 2 files changed, 81 insertions(+) create mode 100644 lms/djangoapps/linkedin/management/commands/login.py diff --git a/lms/djangoapps/linkedin/management/commands/login.py b/lms/djangoapps/linkedin/management/commands/login.py new file mode 100644 index 000000000000..a1411c15f3e6 --- /dev/null +++ b/lms/djangoapps/linkedin/management/commands/login.py @@ -0,0 +1,72 @@ +""" +Log into LinkedIn API. +""" +import json +import urllib2 +import urlparse +import uuid + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from ...models import LinkedInTokens + + +class Command(BaseCommand): + """ + Can take a sysadmin through steps to log into LinkedIn API so that the + findusers script can work. + """ + args = '' + help = ('Takes a user through the steps to log in to LinkedIn as a user ' + 'with API access in order to gain an access token for use by the ' + 'findusers script.') + + def handle(self, *args, **options): + """ + """ + api = getattr(settings, "LINKEDIN_API", None) + if not api: + raise CommandError("LINKEDIN_API is not configured") + + state = str(uuid.uuid4()) + url= ("https://www.linkedin.com/uas/oauth2/authorization" + "?response_type=code" + "&client_id=%s&state=%s&redirect_uri=%s" % ( + api['CLIENT_ID'], state, api['REDIRECT_URI'])) + + print "Let's log into your LinkedIn account." + print "Start by visiting this url:" + print url + print + print "Within 30 seconds of logging in, enter the full URL of the " + print "webpage you were redirected to: " + redirect = raw_input() + query = urlparse.parse_qs(urlparse.urlparse(redirect).query) + assert query['state'][0] == state, (query['state'][0], state) + code = query['code'][0] + + url = ("https://www.linkedin.com/uas/oauth2/accessToken" + "?grant_type=authorization_code" + "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( + code, api['REDIRECT_URI'], api['CLIENT_ID'], + api['CLIENT_SECRET'])) + + try: + response = urllib2.urlopen(url).read() + except urllib2.HTTPError, e: + print "!!ERROR!!" + print e + print e.read() + raise CommandError("Unable to retrieve access token") + + access_token = json.loads(response)['access_token'] + try: + tokens = LinkedInTokens.objects.get() + tokens.access_token = access_token + tokens.authorization_code = code + except LinkedInTokens.DoesNotExist: + tokens = LinkedInTokens( + access_token=access_token, + authorization_code=code) + tokens.save() diff --git a/lms/djangoapps/linkedin/models.py b/lms/djangoapps/linkedin/models.py index 4d7002a8ce74..9fb7b923a2cc 100644 --- a/lms/djangoapps/linkedin/models.py +++ b/lms/djangoapps/linkedin/models.py @@ -12,3 +12,12 @@ class LinkedIn(models.Model): user = models.OneToOneField(User, primary_key=True) has_linkedin_account = models.NullBooleanField(default=None) emailed_courses = models.TextField(default="[]") # JSON list of course ids + + +class LinkedInTokens(models.Model): + """ + For storing access token and authorization code after logging in to + LinkedIn. + """ + access_token = models.CharField(max_length=255) + authorization_code = models.CharField(max_length=255) From 7a48e93575459d693b4e77171c916b6fff521020 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 16 Dec 2013 09:37:44 -0500 Subject: [PATCH 11/48] Did not mean to check in proof of concept script. --- linkedin-login.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 linkedin-login.py diff --git a/linkedin-login.py b/linkedin-login.py deleted file mode 100644 index fcd90c39b366..000000000000 --- a/linkedin-login.py +++ /dev/null @@ -1,36 +0,0 @@ -import urllib2 -import uuid - -CLIENT_ID = "77zv1vf44fo92m" -CLIENT_SECRET = "1M3wZUNA2aYHldbv" -REDIRECT_URI = "http://www.edx.org" -STATE = uuid.uuid4() -SCOPE = "%20".join([ - "r_basicprofile", - "r_fullprofile", - "r_emailaddress", - "r_network", - "r_contactinfo", - "rw_nus", - "rw_company_admin", - "rw_groups", - "w_messages"]) - -print "Go here:" -print ("https://www.linkedin.com/uas/oauth2/authorization?response_type=code" - "&client_id=%s&state=%s&redirect_uri=%s&scope=%s" % - (CLIENT_ID, STATE, REDIRECT_URI, SCOPE)) - -print "Enter authcode: ", -code = raw_input() - -url = ("https://www.linkedin.com/uas/oauth2/accessToken" - "?grant_type=authorization_code" - "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( - code, REDIRECT_URI, CLIENT_ID, CLIENT_SECRET)) -try: - print urllib2.urlopen(url).read() -except urllib2.HTTPError, e: - print "!!ERROR!!" - print e - print e.read() From 2f9e9e2494685f3168d4983cb8bd38b63e5d72ca Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 16 Dec 2013 10:46:45 -0500 Subject: [PATCH 12/48] Refactor findusers script to be fully lazy, in hopes we don't run out of memory with huge numbers of users. --- .../linkedin/management/commands/findusers.py | 55 ++++++++++++------- .../commands/tests/test_findusers.py | 1 + 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/findusers.py b/lms/djangoapps/linkedin/management/commands/findusers.py index 86a67606e021..bcd4b13c1af2 100644 --- a/lms/djangoapps/linkedin/management/commands/findusers.py +++ b/lms/djangoapps/linkedin/management/commands/findusers.py @@ -12,6 +12,8 @@ from optparse import make_option +from ...models import LinkedIn + FRIDAY = 4 @@ -71,32 +73,45 @@ def handle(self, *args, **options): if not max_checks: raise CommandError("No checks allowed during this time.") - check_users = [] - for user in User.objects.all(): - checked = (hasattr(user, 'linkedin') and - user.linkedin.has_linkedin_account is not None) - if recheck or not checked: - check_users.append(user) - - if max_checks != -1 and len(check_users) > max_checks: - self.stderr.write( - "WARNING: limited to checking only %d users today." % - max_checks) - check_users = check_users[:max_checks] - batches = [check_users[i:i + checks_per_call] - for i in xrange(0, len(check_users), checks_per_call)] + def batch_users(): + "Generator to lazily generate batches of users to query." + count = 0 + batch = [] + for user in User.objects.all(): + if not hasattr(user, 'linkedin'): + LinkedIn(user=user).save() + checked = user.linkedin.has_linkedin_account is not None + if recheck or not checked: + batch.append(user) + if len(batch) == checks_per_call: + yield batch + batch = [] + count += 1 + if max_checks != 1 and count == max_checks: + self.stderr.write( + "WARNING: limited to checking only %d users today." + % max_checks) + break + if batch: + yield batch def do_batch(batch): "Process a batch of users." - emails = [u.email for u in batch] + emails = (u.email for u in batch) for user, has_account in zip(batch, api.batch(emails)): - user.linkedin.has_linkedin_account = has_account - - if batches: - do_batch(batches.pop(0)) + linkedin = user.linkedin + if linkedin.has_linkedin_account != has_account: + linkedin.has_linkedin_account = has_account + linkedin.save() + + batches = batch_users() + try: + do_batch(batches.next()) # may raise StopIteration for batch in batches: time.sleep(time_between_calls) do_batch(batch) + except StopIteration: + pass class LinkedinAPI(object): @@ -108,4 +123,4 @@ def batch(self, emails): """ Get the LinkedIn status for a batch of emails. """ - pass + return (True for email in emails) diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py index da779fd8a3bf..2878eca34304 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py @@ -110,6 +110,7 @@ def test_command_success_no_recheck_no_limits(self, get_call_limits, apicls, def dummy_batch(emails): "Mock LinkedIn API." + emails = list(emails) self.assertEqual(len(emails), 4) return [email % 2 == 0 for email in emails] api.batch = dummy_batch From dc9c52d75701ccde484ec36a766d26bd45016605 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 16 Dec 2013 10:56:46 -0500 Subject: [PATCH 13/48] Pep8, pylint. --- .../linkedin/management/commands/login.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/login.py b/lms/djangoapps/linkedin/management/commands/login.py index a1411c15f3e6..9a0f525ee3f5 100644 --- a/lms/djangoapps/linkedin/management/commands/login.py +++ b/lms/djangoapps/linkedin/management/commands/login.py @@ -30,10 +30,10 @@ def handle(self, *args, **options): raise CommandError("LINKEDIN_API is not configured") state = str(uuid.uuid4()) - url= ("https://www.linkedin.com/uas/oauth2/authorization" - "?response_type=code" - "&client_id=%s&state=%s&redirect_uri=%s" % ( - api['CLIENT_ID'], state, api['REDIRECT_URI'])) + url = ("https://www.linkedin.com/uas/oauth2/authorization" + "?response_type=code" + "&client_id=%s&state=%s&redirect_uri=%s" % ( + api['CLIENT_ID'], state, api['REDIRECT_URI'])) print "Let's log into your LinkedIn account." print "Start by visiting this url:" @@ -54,13 +54,13 @@ def handle(self, *args, **options): try: response = urllib2.urlopen(url).read() - except urllib2.HTTPError, e: + except urllib2.HTTPError, error: print "!!ERROR!!" - print e - print e.read() + print error + print error.read() raise CommandError("Unable to retrieve access token") - access_token = json.loads(response)['access_token'] + access_token = json.loads(response)['access_token'] try: tokens = LinkedInTokens.objects.get() tokens.access_token = access_token From ec2678a5c50d3bf1496beda5f4069109186ecc4c Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Tue, 17 Dec 2013 09:11:15 -0500 Subject: [PATCH 14/48] Refactor LinkedIn API code to common location for reuse. --- .../linkedin/management/commands/api.py | 99 +++++++++++++++++++ .../linkedin/management/commands/findusers.py | 13 +-- .../linkedin/management/commands/login.py | 53 ++-------- lms/djangoapps/linkedin/models.py | 3 +- 4 files changed, 107 insertions(+), 61 deletions(-) create mode 100644 lms/djangoapps/linkedin/management/commands/api.py diff --git a/lms/djangoapps/linkedin/management/commands/api.py b/lms/djangoapps/linkedin/management/commands/api.py new file mode 100644 index 000000000000..5b0f46a20900 --- /dev/null +++ b/lms/djangoapps/linkedin/management/commands/api.py @@ -0,0 +1,99 @@ +import hashlib +import json +import urllib2 +import urlparse +import uuid + +from django.conf import settings +from django.core.management.base import CommandError + +from ...models import LinkedInToken + +class LinkedinAPI(object): + """ + Encapsulates the LinkedIn API. + """ + def __init__(self): + config = getattr(settings, "LINKEDIN_API", None) + if not config: + raise CommandError("LINKEDIN_API is not configured") + self.config = config + + try: + self.tokens = LinkedInToken.objects.get() + except LinkedInToken.DoesNotExist: + self.tokens = None + + self.state = str(uuid.uuid4()) + + def http_error(error, message): + print "!!ERROR!!" + print error + print error.read() + raise CommandError(message) + + def authorization_url(self): + config = self.config + return ("https://www.linkedin.com/uas/oauth2/authorization" + "?response_type=code" + "&client_id=%s&state=%s&redirect_uri=%s" % ( + config['CLIENT_ID'], self.state, config['REDIRECT_URI'])) + + def get_authorization_code(self, redirect): + query = urlparse.parse_qs(urlparse.urlparse(redirect).query) + assert query['state'][0] == self.state, (query['state'][0], self.state) + return query['code'][0] + + def get_access_token(self, code): + config = self.config + url = ("https://www.linkedin.com/uas/oauth2/accessToken" + "?grant_type=authorization_code" + "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( + code, config['REDIRECT_URI'], config['CLIENT_ID'], + config['CLIENT_SECRET'])) + + try: + response = urllib2.urlopen(url).read() + except urllib2.HTTPError, error: + self.http_error(error, "Unable to retrieve access token") + + access_token = json.loads(response)['access_token'] + try: + tokens = LinkedInToken.objects.get() + tokens.access_token = access_token + tokens.authorization_code = code + except LinkedInToken.DoesNotExist: + tokens = LinkedInToken(access_token=access_token) + tokens.save() + self.tokens = tokens + + return access_token + + def batch(self, emails): + """ + Get the LinkedIn status for a batch of emails. + """ + if self.tokens is None: + raise CommandError( + "You must log in to LinkedIn in order to use this script. " + "Please use the 'login' command to log in to LinkedIn.") + + def md5(email): + "Compute md5 hash for an email address." + hash = hashlib.md5() + hash.update(email) + return hash.hexdigest() + + hashes = ','.join(("email-hash=" + md5(email) for email in emails)) + url = "https://api.linkedin.com/v1/people::(%s):(id)" % hashes + url += "?oauth2_access_token=%s" % self.tokens.access_token + try: + response = urllib2.urlopen(url).read() + except urllib2.HTTPError, error: + print "!!ERROR!!" + print error + print error.read() + + raise CommandError("Unable to access People API") + + return (True for email in emails) diff --git a/lms/djangoapps/linkedin/management/commands/findusers.py b/lms/djangoapps/linkedin/management/commands/findusers.py index bcd4b13c1af2..5311bfc58135 100644 --- a/lms/djangoapps/linkedin/management/commands/findusers.py +++ b/lms/djangoapps/linkedin/management/commands/findusers.py @@ -13,6 +13,7 @@ from optparse import make_option from ...models import LinkedIn +from .api import LinkedinAPI FRIDAY = 4 @@ -112,15 +113,3 @@ def do_batch(batch): do_batch(batch) except StopIteration: pass - - -class LinkedinAPI(object): - """ - Encapsulates the LinkedIn API. - """ - - def batch(self, emails): - """ - Get the LinkedIn status for a batch of emails. - """ - return (True for email in emails) diff --git a/lms/djangoapps/linkedin/management/commands/login.py b/lms/djangoapps/linkedin/management/commands/login.py index 9a0f525ee3f5..04c16696e46c 100644 --- a/lms/djangoapps/linkedin/management/commands/login.py +++ b/lms/djangoapps/linkedin/management/commands/login.py @@ -1,15 +1,9 @@ """ Log into LinkedIn API. """ -import json -import urllib2 -import urlparse -import uuid +from django.core.management.base import BaseCommand -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError - -from ...models import LinkedInTokens +from .api import LinkedinAPI class Command(BaseCommand): @@ -25,48 +19,13 @@ class Command(BaseCommand): def handle(self, *args, **options): """ """ - api = getattr(settings, "LINKEDIN_API", None) - if not api: - raise CommandError("LINKEDIN_API is not configured") - - state = str(uuid.uuid4()) - url = ("https://www.linkedin.com/uas/oauth2/authorization" - "?response_type=code" - "&client_id=%s&state=%s&redirect_uri=%s" % ( - api['CLIENT_ID'], state, api['REDIRECT_URI'])) - + api = LinkedinAPI() print "Let's log into your LinkedIn account." print "Start by visiting this url:" - print url + print api.authorization_url() print print "Within 30 seconds of logging in, enter the full URL of the " print "webpage you were redirected to: " redirect = raw_input() - query = urlparse.parse_qs(urlparse.urlparse(redirect).query) - assert query['state'][0] == state, (query['state'][0], state) - code = query['code'][0] - - url = ("https://www.linkedin.com/uas/oauth2/accessToken" - "?grant_type=authorization_code" - "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( - code, api['REDIRECT_URI'], api['CLIENT_ID'], - api['CLIENT_SECRET'])) - - try: - response = urllib2.urlopen(url).read() - except urllib2.HTTPError, error: - print "!!ERROR!!" - print error - print error.read() - raise CommandError("Unable to retrieve access token") - - access_token = json.loads(response)['access_token'] - try: - tokens = LinkedInTokens.objects.get() - tokens.access_token = access_token - tokens.authorization_code = code - except LinkedInTokens.DoesNotExist: - tokens = LinkedInTokens( - access_token=access_token, - authorization_code=code) - tokens.save() + code = api.get_authorization_code(redirect) + api.get_access_token(code) diff --git a/lms/djangoapps/linkedin/models.py b/lms/djangoapps/linkedin/models.py index 9fb7b923a2cc..51e001effa90 100644 --- a/lms/djangoapps/linkedin/models.py +++ b/lms/djangoapps/linkedin/models.py @@ -14,10 +14,9 @@ class LinkedIn(models.Model): emailed_courses = models.TextField(default="[]") # JSON list of course ids -class LinkedInTokens(models.Model): +class LinkedInToken(models.Model): """ For storing access token and authorization code after logging in to LinkedIn. """ access_token = models.CharField(max_length=255) - authorization_code = models.CharField(max_length=255) From bb43d4cae446e8b6b25699a3272c339f1cb70f7c Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Tue, 17 Dec 2013 09:36:43 -0500 Subject: [PATCH 15/48] House cleaning. --- .../linkedin/management/commands/__init__.py | 112 ++++++++++++++++++ .../linkedin/management/commands/api.py | 99 ---------------- .../linkedin/management/commands/findusers.py | 2 +- .../linkedin/management/commands/login.py | 2 +- 4 files changed, 114 insertions(+), 101 deletions(-) delete mode 100644 lms/djangoapps/linkedin/management/commands/api.py diff --git a/lms/djangoapps/linkedin/management/commands/__init__.py b/lms/djangoapps/linkedin/management/commands/__init__.py index e69de29bb2d1..5a836bf3fc21 100644 --- a/lms/djangoapps/linkedin/management/commands/__init__.py +++ b/lms/djangoapps/linkedin/management/commands/__init__.py @@ -0,0 +1,112 @@ +""" +Class for accessing LinkedIn's API. +""" +import hashlib +import json +import urllib2 +import urlparse +import uuid + +from django.conf import settings +from django.core.management.base import CommandError + +from ...models import LinkedInToken + + +class LinkedinAPI(object): + """ + Encapsulates the LinkedIn API. + """ + def __init__(self): + config = getattr(settings, "LINKEDIN_API", None) + if not config: + raise CommandError("LINKEDIN_API is not configured") + self.config = config + + try: + self.tokens = LinkedInToken.objects.get() + except LinkedInToken.DoesNotExist: + self.tokens = None + + self.state = str(uuid.uuid4()) + + def http_error(self, error, message): + """ + Handle an unexpected HTTP response. + """ + print "!!ERROR!!" + print error + print error.read() + raise CommandError(message) + + def authorization_url(self): + """ + Synthesize a URL for beginning the authorization flow. + """ + config = self.config + return ("https://www.linkedin.com/uas/oauth2/authorization" + "?response_type=code" + "&client_id=%s&state=%s&redirect_uri=%s" % ( + config['CLIENT_ID'], self.state, config['REDIRECT_URI'])) + + def get_authorization_code(self, redirect): + """ + Extract the authorization code from the redirect URL at the end of + the authorization flow. + """ + query = urlparse.parse_qs(urlparse.urlparse(redirect).query) + assert query['state'][0] == self.state, (query['state'][0], self.state) + return query['code'][0] + + def get_access_token(self, code): + """ + Given an authorization code, get an access token. + """ + config = self.config + url = ("https://www.linkedin.com/uas/oauth2/accessToken" + "?grant_type=authorization_code" + "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( + code, config['REDIRECT_URI'], config['CLIENT_ID'], + config['CLIENT_SECRET'])) + + try: + response = urllib2.urlopen(url).read() + except urllib2.HTTPError, error: + self.http_error(error, "Unable to retrieve access token") + + access_token = json.loads(response)['access_token'] + try: + tokens = LinkedInToken.objects.get() + tokens.access_token = access_token + tokens.authorization_code = code + except LinkedInToken.DoesNotExist: + tokens = LinkedInToken(access_token=access_token) + tokens.save() + self.tokens = tokens + + return access_token + + def batch(self, emails): + """ + Get the LinkedIn status for a batch of emails. + """ + if self.tokens is None: + raise CommandError( + "You must log in to LinkedIn in order to use this script. " + "Please use the 'login' command to log in to LinkedIn.") + + def md5(email): + "Compute md5 hash for an email address." + md5hash = hashlib.md5() + md5hash.update(email) + return md5hash.hexdigest() + + hashes = ','.join(("email-hash=" + md5(email) for email in emails)) + url = "https://api.linkedin.com/v1/people::(%s):(id)" % hashes + url += "?oauth2_access_token=%s" % self.tokens.access_token + try: + response = urllib2.urlopen(url).read() + print "GOT IT!", response + except urllib2.HTTPError, error: + self.http_error(error, "Unable to access People API") + return (True for email in emails) diff --git a/lms/djangoapps/linkedin/management/commands/api.py b/lms/djangoapps/linkedin/management/commands/api.py deleted file mode 100644 index 5b0f46a20900..000000000000 --- a/lms/djangoapps/linkedin/management/commands/api.py +++ /dev/null @@ -1,99 +0,0 @@ -import hashlib -import json -import urllib2 -import urlparse -import uuid - -from django.conf import settings -from django.core.management.base import CommandError - -from ...models import LinkedInToken - -class LinkedinAPI(object): - """ - Encapsulates the LinkedIn API. - """ - def __init__(self): - config = getattr(settings, "LINKEDIN_API", None) - if not config: - raise CommandError("LINKEDIN_API is not configured") - self.config = config - - try: - self.tokens = LinkedInToken.objects.get() - except LinkedInToken.DoesNotExist: - self.tokens = None - - self.state = str(uuid.uuid4()) - - def http_error(error, message): - print "!!ERROR!!" - print error - print error.read() - raise CommandError(message) - - def authorization_url(self): - config = self.config - return ("https://www.linkedin.com/uas/oauth2/authorization" - "?response_type=code" - "&client_id=%s&state=%s&redirect_uri=%s" % ( - config['CLIENT_ID'], self.state, config['REDIRECT_URI'])) - - def get_authorization_code(self, redirect): - query = urlparse.parse_qs(urlparse.urlparse(redirect).query) - assert query['state'][0] == self.state, (query['state'][0], self.state) - return query['code'][0] - - def get_access_token(self, code): - config = self.config - url = ("https://www.linkedin.com/uas/oauth2/accessToken" - "?grant_type=authorization_code" - "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( - code, config['REDIRECT_URI'], config['CLIENT_ID'], - config['CLIENT_SECRET'])) - - try: - response = urllib2.urlopen(url).read() - except urllib2.HTTPError, error: - self.http_error(error, "Unable to retrieve access token") - - access_token = json.loads(response)['access_token'] - try: - tokens = LinkedInToken.objects.get() - tokens.access_token = access_token - tokens.authorization_code = code - except LinkedInToken.DoesNotExist: - tokens = LinkedInToken(access_token=access_token) - tokens.save() - self.tokens = tokens - - return access_token - - def batch(self, emails): - """ - Get the LinkedIn status for a batch of emails. - """ - if self.tokens is None: - raise CommandError( - "You must log in to LinkedIn in order to use this script. " - "Please use the 'login' command to log in to LinkedIn.") - - def md5(email): - "Compute md5 hash for an email address." - hash = hashlib.md5() - hash.update(email) - return hash.hexdigest() - - hashes = ','.join(("email-hash=" + md5(email) for email in emails)) - url = "https://api.linkedin.com/v1/people::(%s):(id)" % hashes - url += "?oauth2_access_token=%s" % self.tokens.access_token - try: - response = urllib2.urlopen(url).read() - except urllib2.HTTPError, error: - print "!!ERROR!!" - print error - print error.read() - - raise CommandError("Unable to access People API") - - return (True for email in emails) diff --git a/lms/djangoapps/linkedin/management/commands/findusers.py b/lms/djangoapps/linkedin/management/commands/findusers.py index 5311bfc58135..30a31d5c00c7 100644 --- a/lms/djangoapps/linkedin/management/commands/findusers.py +++ b/lms/djangoapps/linkedin/management/commands/findusers.py @@ -13,7 +13,7 @@ from optparse import make_option from ...models import LinkedIn -from .api import LinkedinAPI +from . import LinkedinAPI FRIDAY = 4 diff --git a/lms/djangoapps/linkedin/management/commands/login.py b/lms/djangoapps/linkedin/management/commands/login.py index 04c16696e46c..7f835892cb90 100644 --- a/lms/djangoapps/linkedin/management/commands/login.py +++ b/lms/djangoapps/linkedin/management/commands/login.py @@ -3,7 +3,7 @@ """ from django.core.management.base import BaseCommand -from .api import LinkedinAPI +from . import LinkedinAPI class Command(BaseCommand): From 5d7befdebe015bbd7ee25c28fa4103b4c90935a1 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Tue, 17 Dec 2013 12:08:52 -0500 Subject: [PATCH 16/48] Generate certificate URLs. --- .../linkedin/management/commands/mailusers.py | 36 +++++++++++++++++-- .../linkedin/templates/linkedin_email.html | 2 +- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/mailusers.py b/lms/djangoapps/linkedin/management/commands/mailusers.py index b046bd0445df..0cfdb8566238 100644 --- a/lms/djangoapps/linkedin/management/commands/mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/mailusers.py @@ -4,6 +4,7 @@ """ import json +import urllib from courseware.courses import get_course_by_id from django.core.management.base import BaseCommand @@ -14,6 +15,8 @@ from certificates.models import GeneratedCertificate from ...models import LinkedIn +from . import LinkedinAPI + class Command(BaseCommand): """ @@ -58,6 +61,32 @@ def handle(self, *args, **options): emailed.append(certificate.course_id) account.emailed_courses = json.dumps(emailed) +def certificate_url(api, course, certificate, grandfather=False): + """ + Generates a certificate URL based on LinkedIn's documentation. The + documentation is from a Word document: DAT_DOCUMENTATION_v3.12.docx + """ + tracking_code = '-'.join([ + 'eml', + 'prof', # the 'product'--no idea what that's supposed to mean + course.org, # Partner's name + course.number, # Certificate's name + 'gf' if grandfather else 'T']) + query = { + 'pfCertificationName': certificate.name, + 'pfAuthorityName': api.config['COMPANY_NAME'], + 'pfAuthorityId': api.config['COMPANY_ID'], + 'pfCertificationUrl': certificate.download_url, + 'pfLicenseNo': certificate.course_id, + 'pfCertStartDate': course.start.strftime('%Y%mI'), + 'pfCertFuture': certificate.created_date.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(user, certificates): """ @@ -72,10 +101,13 @@ def send_email(user, certificate): Email a user that recently earned a certificate, inviting them to post their certificate on their LinkedIn profile. """ + api = LinkedinAPI() template = get_template("linkedin_email.html") course = get_course_by_id(certificate.course_id) + url = certificate_url(api, course, certificate) context = Context({ 'student_name': user.profile.name, - 'course_name': 'XXX', - 'url': '#'}) + 'course_name': certificate.name, + 'url': url}) print template.render(context) + print url diff --git a/lms/djangoapps/linkedin/templates/linkedin_email.html b/lms/djangoapps/linkedin/templates/linkedin_email.html index 54db13d8949c..a845875dcd09 100644 --- a/lms/djangoapps/linkedin/templates/linkedin_email.html +++ b/lms/djangoapps/linkedin/templates/linkedin_email.html @@ -17,7 +17,7 @@ certificate to your profile. {% endblocktrans %}

-

+

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

From 35fdbf3a1c327fcf58412ffb699315c55bd5fd9e Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Tue, 17 Dec 2013 13:17:28 -0500 Subject: [PATCH 17/48] Send mail. --- .../linkedin/management/commands/mailusers.py | 108 +++++++++--------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/mailusers.py b/lms/djangoapps/linkedin/management/commands/mailusers.py index 0cfdb8566238..e76e2ce44048 100644 --- a/lms/djangoapps/linkedin/management/commands/mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/mailusers.py @@ -6,15 +6,16 @@ import json import urllib -from courseware.courses import get_course_by_id +from django.core.mail import send_mail from django.core.management.base import BaseCommand from django.template import Context from django.template.loader import get_template from optparse import make_option from certificates.models import GeneratedCertificate -from ...models import LinkedIn +from courseware.courses import get_course_by_id +from ...models import LinkedIn from . import LinkedinAPI @@ -40,6 +41,10 @@ class Command(BaseCommand): "certificates. Afterwards the default, one email per " "certificate mail form will be used."),) + def __init__(self): + super(BaseCommand, self).__init__() + self.api = LinkedinAPI() + def handle(self, *args, **options): grandfather = options.get('grandfather', False) accounts = LinkedIn.objects.filter(has_linkedin_account=True) @@ -53,61 +58,62 @@ def handle(self, *args, **options): if not certificates: continue if grandfather: - send_grandfather_email(user, certificates) + self.send_grandfather_email(user, certificates) emailed.extend([cert.course_id for cert in certificates]) else: for certificate in certificates: - send_email(user, certificate) + self.send_email(user, certificate) emailed.append(certificate.course_id) account.emailed_courses = json.dumps(emailed) -def certificate_url(api, course, certificate, grandfather=False): - """ - Generates a certificate URL based on LinkedIn's documentation. The - documentation is from a Word document: DAT_DOCUMENTATION_v3.12.docx - """ - tracking_code = '-'.join([ - 'eml', - 'prof', # the 'product'--no idea what that's supposed to mean - course.org, # Partner's name - course.number, # Certificate's name - 'gf' if grandfather else 'T']) - query = { - 'pfCertificationName': certificate.name, - 'pfAuthorityName': api.config['COMPANY_NAME'], - 'pfAuthorityId': api.config['COMPANY_ID'], - 'pfCertificationUrl': certificate.download_url, - 'pfLicenseNo': certificate.course_id, - 'pfCertStartDate': course.start.strftime('%Y%mI'), - 'pfCertFuture': certificate.created_date.strftime('%Y%m'), - '_mSplash': '1', - 'trk': tracking_code, - 'startTask': 'CERTIFICATION_name', - 'force': 'true', - } - return 'http://www.linkedin.com/profile/guided?' + urllib.urlencode(query) - + def certificate_url(self, course, certificate, grandfather=False): + """ + Generates a certificate URL based on LinkedIn's documentation. The + documentation is from a Word document: DAT_DOCUMENTATION_v3.12.docx + """ + tracking_code = '-'.join([ + 'eml', + 'prof', # the 'product'--no idea what that's supposed to mean + course.org, # Partner's name + course.number, # Certificate's name + 'gf' if grandfather else 'T']) + query = { + 'pfCertificationName': certificate.name, + 'pfAuthorityName': self.api.config['COMPANY_NAME'], + 'pfAuthorityId': self.api.config['COMPANY_ID'], + 'pfCertificationUrl': certificate.download_url, + 'pfLicenseNo': certificate.course_id, + 'pfCertStartDate': course.start.strftime('%Y%mI'), + 'pfCertFuture': certificate.created_date.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(user, certificates): - """ - Send the 'grandfathered' email informing historical students that they may - now post their certificates on their LinkedIn profiles. - """ - print "GRANDFATHER: ", user, certificates + def send_grandfather_email(self, user, certificates): + """ + Send the 'grandfathered' email informing historical students that they + may now post their certificates on their LinkedIn profiles. + """ + print "GRANDFATHER: ", user, certificates + def send_email(self, user, certificate): + """ + Email a user that recently earned a certificate, inviting them to post + their certificate on their LinkedIn profile. + """ + template = get_template("linkedin_email.html") + course = get_course_by_id(certificate.course_id) + url = self.certificate_url(course, certificate) + context = Context({ + 'student_name': user.profile.name, + 'course_name': certificate.name, + 'url': url}) -def send_email(user, certificate): - """ - Email a user that recently earned a certificate, inviting them to post their - certificate on their LinkedIn profile. - """ - api = LinkedinAPI() - template = get_template("linkedin_email.html") - course = get_course_by_id(certificate.course_id) - url = certificate_url(api, course, certificate) - context = Context({ - 'student_name': user.profile.name, - 'course_name': certificate.name, - 'url': url}) - print template.render(context) - print url + subject = 'Congratulations! Put your certificate on LinkedIn' + body = template.render(context) + fromaddr = self.api.config['EMAIL_FROM'] + toaddr = '%s <%s>' % (user.profile.name, user.email) + send_mail(subject, body, fromaddr, (toaddr,)) From 9755216a6122a435decc89233e9a21ab54b5ef28 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Tue, 17 Dec 2013 13:21:47 -0500 Subject: [PATCH 18/48] Use whitelist for testing. --- lms/djangoapps/linkedin/management/commands/mailusers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/linkedin/management/commands/mailusers.py b/lms/djangoapps/linkedin/management/commands/mailusers.py index e76e2ce44048..d511314fb094 100644 --- a/lms/djangoapps/linkedin/management/commands/mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/mailusers.py @@ -46,11 +46,15 @@ def __init__(self): self.api = LinkedinAPI() def handle(self, *args, **options): + whitelist = self.api.config.get('EMAIL_WHITELIST') grandfather = options.get('grandfather', False) accounts = LinkedIn.objects.filter(has_linkedin_account=True) for account in accounts: - emailed = json.loads(account.emailed_courses) user = account.user + if whitelist is not None and user.email not in whitelist: + # Whitelist only certain addresses for testing purposes + continue + emailed = json.loads(account.emailed_courses) certificates = GeneratedCertificate.objects.filter(user=user) certificates = certificates.filter(status='downloadable') certificates = [cert for cert in certificates From 46657b8f35fb5b5bd1ed692f93d624d3b6a51a0d Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Tue, 17 Dec 2013 13:44:52 -0500 Subject: [PATCH 19/48] Grandfather email. --- .../linkedin/management/commands/mailusers.py | 30 ++++++++++++++----- .../templates/linkedin_grandfather_email.html | 28 +++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 lms/djangoapps/linkedin/templates/linkedin_grandfather_email.html diff --git a/lms/djangoapps/linkedin/management/commands/mailusers.py b/lms/djangoapps/linkedin/management/commands/mailusers.py index d511314fb094..2eb5abb897d2 100644 --- a/lms/djangoapps/linkedin/management/commands/mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/mailusers.py @@ -69,12 +69,14 @@ def handle(self, *args, **options): self.send_email(user, certificate) emailed.append(certificate.course_id) account.emailed_courses = json.dumps(emailed) + account.save() - def certificate_url(self, course, certificate, grandfather=False): + def certificate_url(self, certificate, grandfather=False): """ 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 @@ -101,23 +103,37 @@ def send_grandfather_email(self, user, certificates): Send the 'grandfathered' email informing historical students that they may now post their certificates on their LinkedIn profiles. """ - print "GRANDFATHER: ", user, certificates + template = get_template("linkedin_grandfather_email.html") + links = [ + {'course_name': certificate.name, + 'url': self.certificate_url(certificate, grandfather=True)} + for certificate in certificates] + context = Context({ + 'student_name': user.profile.name, + 'certificates': links}) + body = template.render(context) + subject = 'Congratulations! Put your certificates on LinkedIn' + self.send_email(user, subject, body) - def send_email(self, user, certificate): + def send_triggered_email(self, user, certificate): """ Email a user that recently earned a certificate, inviting them to post their certificate on their LinkedIn profile. """ template = get_template("linkedin_email.html") - course = get_course_by_id(certificate.course_id) - url = self.certificate_url(course, certificate) + url = self.certificate_url(certificate) context = Context({ 'student_name': user.profile.name, 'course_name': certificate.name, 'url': url}) - - subject = 'Congratulations! Put your certificate on LinkedIn' body = template.render(context) + subject = 'Congratulations! Put your certificate on LinkedIn' + self.send_email(user, subject, body) + + def send_email(self, user, subject, body): + """ + Send an email. + """ fromaddr = self.api.config['EMAIL_FROM'] toaddr = '%s <%s>' % (user.profile.name, user.email) send_mail(subject, body, fromaddr, (toaddr,)) diff --git a/lms/djangoapps/linkedin/templates/linkedin_grandfather_email.html b/lms/djangoapps/linkedin/templates/linkedin_grandfather_email.html new file mode 100644 index 000000000000..cc373a3d8ed9 --- /dev/null +++ b/lms/djangoapps/linkedin/templates/linkedin_grandfather_email.html @@ -0,0 +1,28 @@ +{% load i18n %} + + + + + + + +

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

+ +

{% blocktrans with name=course_name %} + We've partnered with LinkedIn and now you can put your certificates on + your LinkedIn profile. Just use the links below. + {% endblocktrans %}

+ + {% for cert in certificates %} +

+ {{cert.course_name}}: + + in + {% blocktrans %}Add to profile{% endblocktrans %} + +

+ {% endfor %} + + From 76b15eae65f6fcf28a0b3953bce6f73ef2c68ee2 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Wed, 18 Dec 2013 14:52:53 -0500 Subject: [PATCH 20/48] Get json data. --- lms/djangoapps/linkedin/management/commands/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/linkedin/management/commands/__init__.py b/lms/djangoapps/linkedin/management/commands/__init__.py index 5a836bf3fc21..9e431b0240b6 100644 --- a/lms/djangoapps/linkedin/management/commands/__init__.py +++ b/lms/djangoapps/linkedin/management/commands/__init__.py @@ -104,8 +104,9 @@ def md5(email): hashes = ','.join(("email-hash=" + md5(email) for email in emails)) url = "https://api.linkedin.com/v1/people::(%s):(id)" % hashes url += "?oauth2_access_token=%s" % self.tokens.access_token + request = urllib2.Request(url, headers={'x-li-format': 'json'}) try: - response = urllib2.urlopen(url).read() + response = urllib2.urlopen(request).read() print "GOT IT!", response except urllib2.HTTPError, error: self.http_error(error, "Unable to access People API") From dffa8c66245f822cd7ae4f09f10ac73a854045a5 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 19 Dec 2013 10:39:59 -0500 Subject: [PATCH 21/48] LinkedIn API finally works. --- .../linkedin/management/commands/__init__.py | 16 ++++++---------- .../linkedin/management/commands/findusers.py | 1 + 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/__init__.py b/lms/djangoapps/linkedin/management/commands/__init__.py index 9e431b0240b6..c62c913f826f 100644 --- a/lms/djangoapps/linkedin/management/commands/__init__.py +++ b/lms/djangoapps/linkedin/management/commands/__init__.py @@ -1,7 +1,6 @@ """ Class for accessing LinkedIn's API. """ -import hashlib import json import urllib2 import urlparse @@ -95,19 +94,16 @@ def batch(self, emails): "You must log in to LinkedIn in order to use this script. " "Please use the 'login' command to log in to LinkedIn.") - def md5(email): - "Compute md5 hash for an email address." - md5hash = hashlib.md5() - md5hash.update(email) - return md5hash.hexdigest() - - hashes = ','.join(("email-hash=" + md5(email) for email in emails)) - url = "https://api.linkedin.com/v1/people::(%s):(id)" % hashes + emails = list(emails) # realize generator since we traverse twice + queries = ','.join(("email=" + email for email in emails)) + url = "https://api.linkedin.com/v1/people::(%s):(id)" % queries url += "?oauth2_access_token=%s" % self.tokens.access_token request = urllib2.Request(url, headers={'x-li-format': 'json'}) try: response = urllib2.urlopen(request).read() - print "GOT IT!", response + values = json.loads(response)['values'] + accounts = set(value['_key'][6:] for value in values) + return (email in accounts for email in emails) except urllib2.HTTPError, error: self.http_error(error, "Unable to access People API") return (True for email in emails) diff --git a/lms/djangoapps/linkedin/management/commands/findusers.py b/lms/djangoapps/linkedin/management/commands/findusers.py index 30a31d5c00c7..0e4cb87e2c65 100644 --- a/lms/djangoapps/linkedin/management/commands/findusers.py +++ b/lms/djangoapps/linkedin/management/commands/findusers.py @@ -34,6 +34,7 @@ def get_call_limits(): Use 80 emails per API call and 1 call per second. """ + return -1, 80, 1 now = timezone.now().astimezone(pytz.timezone('US/Pacific')) lastfriday = now while lastfriday.weekday() != FRIDAY: From d7d7327d2b162fa3b0701735a5bde6ad974d384d Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 19 Dec 2013 11:04:35 -0500 Subject: [PATCH 22/48] Allow force checking, disregarding LinkedIn's rules. --- .../linkedin/management/commands/findusers.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/findusers.py b/lms/djangoapps/linkedin/management/commands/findusers.py index 0e4cb87e2c65..2f2a14ffee24 100644 --- a/lms/djangoapps/linkedin/management/commands/findusers.py +++ b/lms/djangoapps/linkedin/management/commands/findusers.py @@ -34,7 +34,6 @@ def get_call_limits(): Use 80 emails per API call and 1 call per second. """ - return -1, 80, 1 now = timezone.now().astimezone(pytz.timezone('US/Pacific')) lastfriday = now while lastfriday.weekday() != FRIDAY: @@ -63,7 +62,14 @@ class Command(BaseCommand): dest='recheck', default=False, help='Check users that have been checked in the past to see if ' - 'they have joined or left LinkedIn since the last check'),) + 'they have joined or left LinkedIn since the last check'), + make_option( + '--force', + action='store_true', + dest='force', + default=False, + help='Disregard the parameters provided by LinkedIn about when it ' + 'is appropriate to make API calls.')) def handle(self, *args, **options): """ @@ -71,9 +77,13 @@ def handle(self, *args, **options): """ api = LinkedinAPI() recheck = options.pop('recheck', False) - max_checks, checks_per_call, time_between_calls = get_call_limits() - if not max_checks: - raise CommandError("No checks allowed during this time.") + force = options.pop('force', False) + if force: + max_checks, checks_per_call, time_between_calls = -1, 80, 1 + else: + max_checks, checks_per_call, time_between_calls = get_call_limits() + if not max_checks: + raise CommandError("No checks allowed during this time.") def batch_users(): "Generator to lazily generate batches of users to query." From cf4ae1615b311305c4e6d99027fac1d52b24e174 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 19 Dec 2013 11:52:56 -0500 Subject: [PATCH 23/48] Disambiguate script names. Unfortunately verbose. --- .../{findusers.py => linkedin_findusers.py} | 0 .../commands/{login.py => linkedin_login.py} | 0 .../{mailusers.py => linkedin_mailusers.py} | 0 .../commands/tests/test_findusers.py | 43 ++++++++++--------- .../commands/tests/test_mailusers.py | 22 +++++----- 5 files changed, 34 insertions(+), 31 deletions(-) rename lms/djangoapps/linkedin/management/commands/{findusers.py => linkedin_findusers.py} (100%) rename lms/djangoapps/linkedin/management/commands/{login.py => linkedin_login.py} (100%) rename lms/djangoapps/linkedin/management/commands/{mailusers.py => linkedin_mailusers.py} (100%) diff --git a/lms/djangoapps/linkedin/management/commands/findusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py similarity index 100% rename from lms/djangoapps/linkedin/management/commands/findusers.py rename to lms/djangoapps/linkedin/management/commands/linkedin_findusers.py diff --git a/lms/djangoapps/linkedin/management/commands/login.py b/lms/djangoapps/linkedin/management/commands/linkedin_login.py similarity index 100% rename from lms/djangoapps/linkedin/management/commands/login.py rename to lms/djangoapps/linkedin/management/commands/linkedin_login.py diff --git a/lms/djangoapps/linkedin/management/commands/mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py similarity index 100% rename from lms/djangoapps/linkedin/management/commands/mailusers.py rename to lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py index 2878eca34304..7984e9fc49b1 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py @@ -7,8 +7,9 @@ import StringIO import unittest +from linkedin.management.commands import linkedin_findusers as findusers -from linkedin.management.commands import findusers +MODULE = 'linkedin.management.commands.linkedin_findusers.' class FindUsersTests(unittest.TestCase): @@ -16,7 +17,7 @@ class FindUsersTests(unittest.TestCase): Tests for the findusers script. """ - @mock.patch('linkedin.management.commands.findusers.timezone') + @mock.patch(MODULE + 'timezone') def test_get_call_limits_in_safe_harbor(self, timezone): """ We should be able to perform unlimited API calls during "safe harbor". @@ -33,7 +34,7 @@ def test_get_call_limits_in_safe_harbor(self, timezone): 2013, 12, 15, 7, 59, tzinfo=tzinfo) self.assertEqual(fut(), (-1, 80, 1)) - @mock.patch('linkedin.management.commands.findusers.timezone') + @mock.patch(MODULE + 'timezone') def test_get_call_limits_in_business_hours(self, timezone): """ During business hours we shouldn't be able to make any API calls. @@ -50,7 +51,7 @@ def test_get_call_limits_in_business_hours(self, timezone): 2013, 12, 16, 8, 1, tzinfo=tzinfo) self.assertEqual(fut(), (0, 0, 0)) - @mock.patch('linkedin.management.commands.findusers.timezone') + @mock.patch(MODULE + 'timezone') def test_get_call_limits_on_weeknights(self, timezone): """ On weeknights outside of "safe harbor" we can only make limited API @@ -65,10 +66,10 @@ def test_get_call_limits_on_weeknights(self, timezone): 2013, 12, 11, 7, 59, tzinfo=tzinfo) self.assertEqual(fut(), (500, 80, 1)) - @mock.patch('linkedin.management.commands.findusers.time') - @mock.patch('linkedin.management.commands.findusers.User') - @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') - @mock.patch('linkedin.management.commands.findusers.get_call_limits') + @mock.patch(MODULE + 'time') + @mock.patch(MODULE + 'User') + @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'get_call_limits') def test_command_success_recheck_no_limits(self, get_call_limits, apicls, usercls, time): """ @@ -89,10 +90,10 @@ def dummy_batch(emails): self.assertEqual([u.linkedin.has_linkedin_account for u in users], [i % 2 == 0 for i in xrange(10)]) - @mock.patch('linkedin.management.commands.findusers.time') - @mock.patch('linkedin.management.commands.findusers.User') - @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') - @mock.patch('linkedin.management.commands.findusers.get_call_limits') + @mock.patch(MODULE + 'time') + @mock.patch(MODULE + 'User') + @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'get_call_limits') def test_command_success_no_recheck_no_limits(self, get_call_limits, apicls, usercls, time): """ @@ -119,10 +120,10 @@ def dummy_batch(emails): self.assertEqual([u.linkedin.has_linkedin_account for u in users], [i % 2 == 0 for i in xrange(10)]) - @mock.patch('linkedin.management.commands.findusers.time') - @mock.patch('linkedin.management.commands.findusers.User') - @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') - @mock.patch('linkedin.management.commands.findusers.get_call_limits') + @mock.patch(MODULE + 'time') + @mock.patch(MODULE + 'User') + @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'get_call_limits') def test_command_success_no_recheck_no_users(self, get_call_limits, apicls, usercls, time): """ @@ -145,10 +146,10 @@ def dummy_batch(_): self.assertEqual([u.linkedin.has_linkedin_account for u in users], [i % 2 == 0 for i in xrange(10)]) - @mock.patch('linkedin.management.commands.findusers.time') - @mock.patch('linkedin.management.commands.findusers.User') - @mock.patch('linkedin.management.commands.findusers.LinkedinAPI') - @mock.patch('linkedin.management.commands.findusers.get_call_limits') + @mock.patch(MODULE + 'time') + @mock.patch(MODULE + 'User') + @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'get_call_limits') def test_command_success_recheck_with_limit(self, get_call_limits, apicls, usercls, time): """ @@ -175,7 +176,7 @@ def dummy_batch(emails): self.assertEqual(users[9].linkedin.has_linkedin_account, None) self.assertTrue(command.stderr.getvalue().startswith("WARNING")) - @mock.patch('linkedin.management.commands.findusers.get_call_limits') + @mock.patch(MODULE + 'get_call_limits') def test_command_no_api_calls(self, get_call_limits): """ Test rechecking all users with no API limits. diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index e821de1ffb50..273a2459fc8e 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -5,7 +5,9 @@ import mock import unittest -from linkedin.management.commands import mailusers +from linkedin.management.commands import linkedin_mailusers as mailusers + +MODULE = 'linkedin.management.commands.linkedin_mailusers.' class MailusersTests(unittest.TestCase): @@ -13,9 +15,9 @@ class MailusersTests(unittest.TestCase): Test mail users command. """ - @mock.patch('linkedin.management.commands.mailusers.send_email') - @mock.patch('linkedin.management.commands.mailusers.GeneratedCertificate') - @mock.patch('linkedin.management.commands.mailusers.LinkedIn') + @mock.patch(MODULE + 'send_triggered_email') + @mock.patch(MODULE + 'GeneratedCertificate') + @mock.patch(MODULE + 'LinkedIn') def test_mail_users(self, linkedin, certificates, send_email): """ Test emailing users. @@ -48,9 +50,9 @@ def filter_user(user): self.assertEqual(json.loads(fred.emailed_courses), [1, 2]) self.assertEqual(json.loads(barney.emailed_courses), [3]) - @mock.patch('linkedin.management.commands.mailusers.send_grandfather_email') - @mock.patch('linkedin.management.commands.mailusers.GeneratedCertificate') - @mock.patch('linkedin.management.commands.mailusers.LinkedIn') + @mock.patch(MODULE + 'send_grandfather_email') + @mock.patch(MODULE + 'GeneratedCertificate') + @mock.patch(MODULE + 'LinkedIn') def test_mail_users_grandfather(self, linkedin, certificates, send_email): """ Test sending grandfather emails. @@ -82,9 +84,9 @@ def filter_user(user): self.assertEqual(json.loads(fred.emailed_courses), [1, 2]) self.assertEqual(json.loads(barney.emailed_courses), [3]) - @mock.patch('linkedin.management.commands.mailusers.send_email') - @mock.patch('linkedin.management.commands.mailusers.GeneratedCertificate') - @mock.patch('linkedin.management.commands.mailusers.LinkedIn') + @mock.patch(MODULE + 'send_triggered_email') + @mock.patch(MODULE + 'GeneratedCertificate') + @mock.patch(MODULE + 'LinkedIn') def test_mail_users_only_new_courses(self, linkedin, certificates, send_email): """ From 5803b2e3d31878e37254fc37a250ecabcfc60e5b Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 19 Dec 2013 11:53:13 -0500 Subject: [PATCH 24/48] Don't use linkedin app by default. --- lms/envs/dev.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/lms/envs/dev.py b/lms/envs/dev.py index e0801a4173a9..6b4446893bd1 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -293,6 +293,3 @@ from .private import * # pylint: disable=F0401 except ImportError: pass - -####################### Linkedin ########################### -INSTALLED_APPS += ('linkedin',) From dc5dfe625979e22ced185c9c43dac02785c37fdf Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 19 Dec 2013 12:29:10 -0500 Subject: [PATCH 25/48] Some docs. --- lms/djangoapps/linkedin/README.rst | 121 +++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 lms/djangoapps/linkedin/README.rst 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. From fd1d98f7dedd115b67144d2aa279e08e1bc5a7f4 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 19 Dec 2013 17:29:20 -0500 Subject: [PATCH 26/48] Fix tests, one bug. Adopt a more Django-ish testing style. --- .../management/commands/linkedin_mailusers.py | 5 +- .../commands/tests/test_mailusers.py | 144 +++++++----------- lms/envs/test.py | 7 + 3 files changed, 70 insertions(+), 86 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 2eb5abb897d2..6a81b617a96d 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -57,16 +57,19 @@ def handle(self, *args, **options): emailed = json.loads(account.emailed_courses) certificates = GeneratedCertificate.objects.filter(user=user) certificates = certificates.filter(status='downloadable') + print 'HUH?', certificates certificates = [cert for cert in certificates if cert.course_id not in emailed] + print 'DUH?', certificates if not certificates: continue + print 'WTF?', emailed if grandfather: self.send_grandfather_email(user, certificates) emailed.extend([cert.course_id for cert in certificates]) else: for certificate in certificates: - self.send_email(user, certificate) + self.send_triggered_email(user, certificate) emailed.append(certificate.course_id) account.emailed_courses = json.dumps(emailed) account.save() diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index 273a2459fc8e..7a7afa1f2ea1 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -3,119 +3,93 @@ """ import json import mock -import unittest +from certificates.models import GeneratedCertificate +from django.contrib.auth.models import User +from django.test import TestCase + +from student.models import UserProfile +from linkedin.models import LinkedIn from linkedin.management.commands import linkedin_mailusers as mailusers MODULE = 'linkedin.management.commands.linkedin_mailusers.' -class MailusersTests(unittest.TestCase): +class MailusersTests(TestCase): """ Test mail users command. """ - @mock.patch(MODULE + 'send_triggered_email') - @mock.patch(MODULE + 'GeneratedCertificate') - @mock.patch(MODULE + 'LinkedIn') - def test_mail_users(self, linkedin, certificates, send_email): + def setUp(self): + courses = { + 'TEST1': mock.Mock(org='TestX', number='1'), + 'TEST2': mock.Mock(org='TestX', number='2'), + 'TEST3': mock.Mock(org='TestX', number='3'), + } + def get_course_by_id(id): + return courses.get(id) + patcher = mock.patch(MODULE + 'get_course_by_id', get_course_by_id) + patcher.start() + self.addCleanup(patcher.stop) + + self.fred = fred = User(username='fred') + fred.save() + UserProfile(user=fred, name='Fred Flintstone').save() + LinkedIn(user=fred, has_linkedin_account=True).save() + self.barney = barney = User(username='barney') + barney.save() + LinkedIn(user=barney, has_linkedin_account=True).save() + UserProfile(user=barney, name='Barney Rubble').save() + + cert1 = GeneratedCertificate( + status='downloadable', + user=fred, + course_id='TEST1') + cert1.save() + cert2 = GeneratedCertificate( + status='downloadable', + user=fred, + course_id='TEST2') + cert2.save() + cert3 = GeneratedCertificate( + status='downloadable', + user=barney, + course_id='TEST3') + cert3.save() + + def test_mail_users(self): """ Test emailing users. """ fut = mailusers.Command().handle - cert1 = mock.Mock(course_id=1) - cert2 = mock.Mock(course_id=2) - cert3 = mock.Mock(course_id=3) - fred = mock.Mock( - emailed_courses="[]", - user=mock.Mock(certificates=[cert1, cert2])) - barney = mock.Mock( - emailed_courses="[]", - user=mock.Mock(certificates=[cert3])) - linkedin.objects.filter.return_value = [fred, barney] - - def filter_user(user): - "Mock querying the database." - queryset = mock.Mock() - queryset.filter.return_value = user.certificates - return queryset - - certificates.objects.filter = filter_user fut() self.assertEqual( - send_email.call_args_list, - [((fred.user, cert1),), - ((fred.user, cert2),), - ((barney.user, cert3),)]) - self.assertEqual(json.loads(fred.emailed_courses), [1, 2]) - self.assertEqual(json.loads(barney.emailed_courses), [3]) + json.loads(self.fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) + self.assertEqual( + json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) - @mock.patch(MODULE + 'send_grandfather_email') - @mock.patch(MODULE + 'GeneratedCertificate') - @mock.patch(MODULE + 'LinkedIn') - def test_mail_users_grandfather(self, linkedin, certificates, send_email): + def test_mail_users_grandfather(self): """ Test sending grandfather emails. """ fut = mailusers.Command().handle - cert1 = mock.Mock(course_id=1) - cert2 = mock.Mock(course_id=2) - cert3 = mock.Mock(course_id=3) - fred = mock.Mock( - emailed_courses="[]", - user=mock.Mock(certificates=[cert1, cert2])) - barney = mock.Mock( - emailed_courses="[]", - user=mock.Mock(certificates=[cert3])) - linkedin.objects.filter.return_value = [fred, barney] - - def filter_user(user): - "Mock querying the database." - queryset = mock.Mock() - queryset.filter.return_value = user.certificates - return queryset - - certificates.objects.filter = filter_user fut(grandfather=True) self.assertEqual( - send_email.call_args_list, - [((fred.user, [cert1, cert2]),), - ((barney.user, [cert3]),)]) - self.assertEqual(json.loads(fred.emailed_courses), [1, 2]) - self.assertEqual(json.loads(barney.emailed_courses), [3]) + json.loads(self.fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) + self.assertEqual( + json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) - @mock.patch(MODULE + 'send_triggered_email') - @mock.patch(MODULE + 'GeneratedCertificate') - @mock.patch(MODULE + 'LinkedIn') - def test_mail_users_only_new_courses(self, linkedin, certificates, - send_email): + def test_mail_users_only_new_courses(self): """ Test emailing users, making sure they are only emailed about new certificates. """ + self.fred.linkedin.emailed_courses = json.dumps(['TEST1']) + self.fred.linkedin.save() fut = mailusers.Command().handle - cert1 = mock.Mock(course_id=1) - cert2 = mock.Mock(course_id=2) - cert3 = mock.Mock(course_id=3) - fred = mock.Mock( - emailed_courses="[1]", - user=mock.Mock(certificates=[cert1, cert2])) - barney = mock.Mock( - emailed_courses="[]", - user=mock.Mock(certificates=[cert3])) - linkedin.objects.filter.return_value = [fred, barney] - - def filter_user(user): - "Mock querying the database." - queryset = mock.Mock() - queryset.filter.return_value = user.certificates - return queryset - - certificates.objects.filter = filter_user fut() + fred = User.objects.get(username='fred') + self.assertEqual( + json.loads(fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) self.assertEqual( - send_email.call_args_list, - [((fred.user, cert2),), - ((barney.user, cert3),)]) - self.assertEqual(json.loads(fred.emailed_courses), [1, 2]) - self.assertEqual(json.loads(barney.emailed_courses), [3]) + json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) diff --git a/lms/envs/test.py b/lms/envs/test.py index 9577fa571a4b..e718cc3f7117 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -257,6 +257,13 @@ YOUTUBE_PORT = 8031 LTI_PORT = 8765 +############################ LinkedIn Integration ############################# +INSTALLED_APPS += ('linkedin',) +LINKEDIN_API = { + 'COMPANY_NAME': 'edX', + 'COMPANY_ID': '0000000', + 'EMAIL_FROM': 'The Team ', +} ################### Make tests faster From 0a5c25c6ea7659ab3ce85f74c0fc5b322e7ee126 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 20 Dec 2013 09:56:30 -0500 Subject: [PATCH 27/48] 100% test coverage for mailusers. --- .../management/commands/linkedin_mailusers.py | 28 +++---- .../commands/tests/test_mailusers.py | 80 +++++++++++++++++-- 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 6a81b617a96d..67a98e4344c5 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -57,13 +57,10 @@ def handle(self, *args, **options): emailed = json.loads(account.emailed_courses) certificates = GeneratedCertificate.objects.filter(user=user) certificates = certificates.filter(status='downloadable') - print 'HUH?', certificates certificates = [cert for cert in certificates if cert.course_id not in emailed] - print 'DUH?', certificates if not certificates: continue - print 'WTF?', emailed if grandfather: self.send_grandfather_email(user, certificates) emailed.extend([cert.course_id for cert in certificates]) @@ -86,19 +83,18 @@ def certificate_url(self, certificate, grandfather=False): course.org, # Partner's name course.number, # Certificate's name 'gf' if grandfather else 'T']) - query = { - 'pfCertificationName': certificate.name, - 'pfAuthorityName': self.api.config['COMPANY_NAME'], - 'pfAuthorityId': self.api.config['COMPANY_ID'], - 'pfCertificationUrl': certificate.download_url, - 'pfLicenseNo': certificate.course_id, - 'pfCertStartDate': course.start.strftime('%Y%mI'), - 'pfCertFuture': certificate.created_date.strftime('%Y%m'), - '_mSplash': '1', - 'trk': tracking_code, - 'startTask': 'CERTIFICATION_name', - 'force': 'true', - } + query = [ + ('pfCertificationName', certificate.name), + ('pfAuthorityName', self.api.config['COMPANY_NAME']), + ('pfAuthorityId', self.api.config['COMPANY_ID']), + ('pfCertificationUrl', certificate.download_url), + ('pfLicenseNo', certificate.course_id), + ('pfCertStartDate', course.start.strftime('%Y%mI')), + ('pfCertFuture', certificate.created_date.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): diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index 7a7afa1f2ea1..1018758cb25c 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -1,11 +1,13 @@ """ Test email scripts. """ +import datetime import json import mock from certificates.models import GeneratedCertificate from django.contrib.auth.models import User +from django.core import mail from django.test import TestCase from student.models import UserProfile @@ -22,7 +24,8 @@ class MailusersTests(TestCase): def setUp(self): courses = { - 'TEST1': mock.Mock(org='TestX', number='1'), + 'TEST1': mock.Mock(org='TestX', number='1', + start=datetime.datetime(2010, 5, 12, 2, 42)), 'TEST2': mock.Mock(org='TestX', number='2'), 'TEST3': mock.Mock(org='TestX', number='3'), } @@ -32,19 +35,22 @@ def get_course_by_id(id): patcher.start() self.addCleanup(patcher.stop) - self.fred = fred = User(username='fred') + 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') + 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() - cert1 = GeneratedCertificate( + self.cert1 = cert1 = GeneratedCertificate( status='downloadable', user=fred, - course_id='TEST1') + course_id='TEST1', + name='TestX/Intro101', + download_url='http://test.foo/test') cert1.save() cert2 = GeneratedCertificate( status='downloadable', @@ -67,6 +73,28 @@ def test_mail_users(self): json.loads(self.fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) self.assertEqual( json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) + self.assertEqual(len(mail.outbox), 3) + self.assertEqual(mail.outbox[0].from_email, 'The Team ') + self.assertEqual( + mail.outbox[0].to, ['Fred Flintstone ']) + self.assertEqual( + mail.outbox[1].to, ['Fred Flintstone ']) + self.assertEqual( + mail.outbox[2].to, ['Barney Rubble ']) + + @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), ['TEST3']) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].to, ['Barney Rubble ']) def test_mail_users_grandfather(self): """ @@ -78,6 +106,11 @@ def test_mail_users_grandfather(self): json.loads(self.fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) self.assertEqual( json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) + self.assertEqual(len(mail.outbox), 2) + self.assertEqual( + mail.outbox[0].to, ['Fred Flintstone ']) + self.assertEqual( + mail.outbox[1].to, ['Barney Rubble ']) def test_mail_users_only_new_courses(self): """ @@ -93,3 +126,40 @@ def test_mail_users_only_new_courses(self): json.loads(fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) self.assertEqual( json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) + self.assertEqual(len(mail.outbox), 2) + self.assertEqual( + mail.outbox[0].to, ['Fred Flintstone ']) + self.assertEqual( + mail.outbox[1].to, ['Barney Rubble ']) + + def test_mail_users_barney_has_no_new_courses(self): + """ + Test emailing users, making sure they are only emailed about new + certificates. + """ + self.barney.linkedin.emailed_courses = json.dumps(['TEST3']) + self.barney.linkedin.save() + fut = mailusers.Command().handle + fut() + fred = User.objects.get(username='fred') + self.assertEqual( + json.loads(fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) + self.assertEqual( + json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) + self.assertEqual(len(mail.outbox), 2) + self.assertEqual( + mail.outbox[0].to, ['Fred Flintstone ']) + self.assertEqual( + mail.outbox[1].to, ['Fred Flintstone ']) + + def test_certificate_url(self): + self.cert1.created_date = datetime.datetime(2010, 8, 15, 0, 0) + self.cert1.save() + fut = mailusers.Command().certificate_url + self.assertEqual(fut(self.cert1), + 'http://www.linkedin.com/profile/guided?' + 'pfCertificationName=TestX%2FIntro101&pfAuthorityName=edX&' + 'pfAuthorityId=0000000&' + 'pfCertificationUrl=http%3A%2F%2Ftest.foo%2Ftest&pfLicenseNo=TEST1&' + 'pfCertStartDate=201005I&pfCertFuture=201008&_mSplash=1&' + 'trk=eml-prof-TestX-1-T&startTask=CERTIFICATION_NAME&force=true') From 38ad26d594e38baa8174f0e22464130907a05a0f Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 20 Dec 2013 10:25:02 -0500 Subject: [PATCH 28/48] Test --force. --- .../commands/tests/test_findusers.py | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py index 7984e9fc49b1..984ee5cd0883 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py @@ -5,14 +5,15 @@ import mock import pytz import StringIO -import unittest + +from django.test import TestCase from linkedin.management.commands import linkedin_findusers as findusers MODULE = 'linkedin.management.commands.linkedin_findusers.' -class FindUsersTests(unittest.TestCase): +class FindUsersTests(TestCase): """ Tests for the findusers script. """ @@ -176,6 +177,32 @@ def dummy_batch(emails): self.assertEqual(users[9].linkedin.has_linkedin_account, None) self.assertTrue(command.stderr.getvalue().startswith("WARNING")) + @mock.patch(MODULE + 'User') + @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'get_call_limits') + def test_command_success_recheck_with_force(self, get_call_limits, apicls, + usercls): + """ + Test recheck all users with API limit. + """ + command = findusers.Command() + command.stderr = StringIO.StringIO() + fut = command.handle + get_call_limits.return_value = (9, 6, 42) + api = apicls.return_value + users = [mock.Mock(email=i) for i in xrange(10)] + for user in users: + user.linkedin.has_linkedin_account = None + usercls.objects.all.return_value = users + + def dummy_batch(emails): + "Mock LinkedIn API." + return [email % 2 == 0 for email in emails] + api.batch = dummy_batch + fut(force=True) + self.assertEqual([u.linkedin.has_linkedin_account for u in users], + [i % 2 == 0 for i in xrange(10)]) + @mock.patch(MODULE + 'get_call_limits') def test_command_no_api_calls(self, get_call_limits): """ From cf98cb63d6b9761c86a28be40e527765320d3eca Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 20 Dec 2013 10:28:44 -0500 Subject: [PATCH 29/48] Squash warning. --- .../linkedin/management/commands/tests/test_mailusers.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index 1018758cb25c..3f84c4f23b1a 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -8,6 +8,7 @@ from certificates.models import GeneratedCertificate from django.contrib.auth.models import User from django.core import mail +from django.utils.timezone import utc from django.test import TestCase from student.models import UserProfile @@ -24,8 +25,9 @@ class MailusersTests(TestCase): def setUp(self): courses = { - 'TEST1': mock.Mock(org='TestX', number='1', - start=datetime.datetime(2010, 5, 12, 2, 42)), + 'TEST1': mock.Mock( + org='TestX', number='1', + start=datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc)), 'TEST2': mock.Mock(org='TestX', number='2'), 'TEST3': mock.Mock(org='TestX', number='3'), } @@ -153,7 +155,8 @@ def test_mail_users_barney_has_no_new_courses(self): mail.outbox[1].to, ['Fred Flintstone ']) def test_certificate_url(self): - self.cert1.created_date = datetime.datetime(2010, 8, 15, 0, 0) + 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), From c5c9554dbc3e7328d73197e2b81c1c464f74be25 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 20 Dec 2013 11:42:41 -0500 Subject: [PATCH 30/48] Test LinkedinAPI --- .../linkedin/management/commands/__init__.py | 85 ++++++------ .../management/commands/linkedin_findusers.py | 2 +- .../management/commands/linkedin_login.py | 2 +- .../management/commands/linkedin_mailusers.py | 2 +- .../management/commands/tests/test_api.py | 123 ++++++++++++++++++ lms/envs/test.py | 3 + 6 files changed, 174 insertions(+), 43 deletions(-) create mode 100644 lms/djangoapps/linkedin/management/commands/tests/test_api.py diff --git a/lms/djangoapps/linkedin/management/commands/__init__.py b/lms/djangoapps/linkedin/management/commands/__init__.py index c62c913f826f..0a97a94823d3 100644 --- a/lms/djangoapps/linkedin/management/commands/__init__.py +++ b/lms/djangoapps/linkedin/management/commands/__init__.py @@ -16,26 +16,28 @@ class LinkedinAPI(object): """ Encapsulates the LinkedIn API. """ - def __init__(self): + def __init__(self, command): config = getattr(settings, "LINKEDIN_API", None) if not config: raise CommandError("LINKEDIN_API is not configured") self.config = config try: - self.tokens = LinkedInToken.objects.get() + self.token = LinkedInToken.objects.get() except LinkedInToken.DoesNotExist: - self.tokens = None + self.token = None + self.command = command self.state = str(uuid.uuid4()) def http_error(self, error, message): """ Handle an unexpected HTTP response. """ - print "!!ERROR!!" - print error - print error.read() + stderr = self.command.stderr + stderr.write("!!ERROR!!") + stderr.write(error) + stderr.write(error.read()) raise CommandError(message) def authorization_url(self): @@ -57,53 +59,56 @@ def get_authorization_code(self, redirect): assert query['state'][0] == self.state, (query['state'][0], self.state) return query['code'][0] - def get_access_token(self, code): - """ - Given an authorization code, get an access token. - """ + def access_token_url(self, code): config = self.config - url = ("https://www.linkedin.com/uas/oauth2/accessToken" - "?grant_type=authorization_code" - "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( - code, config['REDIRECT_URI'], config['CLIENT_ID'], - config['CLIENT_SECRET'])) + return ("https://www.linkedin.com/uas/oauth2/accessToken" + "?grant_type=authorization_code" + "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( + code, config['REDIRECT_URI'], config['CLIENT_ID'], + config['CLIENT_SECRET'])) + def call_json_api(self, url): try: - response = urllib2.urlopen(url).read() + request = urllib2.Request(url, headers={'x-li-format': 'json'}) + response = urllib2.urlopen(request).read() + return json.loads(response) except urllib2.HTTPError, error: - self.http_error(error, "Unable to retrieve access token") + self.http_error(error, "Error calling LinkedIn API") - access_token = json.loads(response)['access_token'] + def get_access_token(self, code): + """ + Given an authorization code, get an access token. + """ + response = self.call_json_api(self.access_token_url(code)) + access_token = response['access_token'] try: - tokens = LinkedInToken.objects.get() - tokens.access_token = access_token - tokens.authorization_code = code + token = LinkedInToken.objects.get() + token.access_token = access_token except LinkedInToken.DoesNotExist: - tokens = LinkedInToken(access_token=access_token) - tokens.save() - self.tokens = tokens + token = LinkedInToken(access_token=access_token) + token.save() + self.token = token return access_token - def batch(self, emails): - """ - Get the LinkedIn status for a batch of emails. - """ - if self.tokens is None: + def require_token(self): + if self.token is None: raise CommandError( "You must log in to LinkedIn in order to use this script. " "Please use the 'login' command to log in to LinkedIn.") - emails = list(emails) # realize generator since we traverse twice + def batch_url(self, emails): + self.require_token() queries = ','.join(("email=" + email for email in emails)) url = "https://api.linkedin.com/v1/people::(%s):(id)" % queries - url += "?oauth2_access_token=%s" % self.tokens.access_token - request = urllib2.Request(url, headers={'x-li-format': 'json'}) - try: - response = urllib2.urlopen(request).read() - values = json.loads(response)['values'] - accounts = set(value['_key'][6:] for value in values) - return (email in accounts for email in emails) - except urllib2.HTTPError, error: - self.http_error(error, "Unable to access People API") - return (True for email in emails) + url += "?oauth2_access_token=%s" % self.token.access_token + return url + + def batch(self, emails): + """ + Get the LinkedIn status for a batch of emails. + """ + emails = list(emails) # realize generator since we traverse twice + response = self.call_json_api(self.batch_url(emails)) + accounts = set(value['_key'][6:] for value in response['values']) + return (email in accounts for email in emails) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py index 2f2a14ffee24..520b89168e1a 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py @@ -75,7 +75,7 @@ def handle(self, *args, **options): """ Check users. """ - api = LinkedinAPI() + api = LinkedinAPI(self) recheck = options.pop('recheck', False) force = options.pop('force', False) if force: diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_login.py b/lms/djangoapps/linkedin/management/commands/linkedin_login.py index 7f835892cb90..b2df0112c38b 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_login.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_login.py @@ -19,7 +19,7 @@ class Command(BaseCommand): def handle(self, *args, **options): """ """ - api = LinkedinAPI() + api = LinkedinAPI(self) print "Let's log into your LinkedIn account." print "Start by visiting this url:" print api.authorization_url() diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 67a98e4344c5..b02fafeabebd 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -43,7 +43,7 @@ class Command(BaseCommand): def __init__(self): super(BaseCommand, self).__init__() - self.api = LinkedinAPI() + self.api = LinkedinAPI(self) def handle(self, *args, **options): whitelist = self.api.config.get('EMAIL_WHITELIST') diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_api.py b/lms/djangoapps/linkedin/management/commands/tests/test_api.py new file mode 100644 index 000000000000..8dc2915a48c3 --- /dev/null +++ b/lms/djangoapps/linkedin/management/commands/tests/test_api.py @@ -0,0 +1,123 @@ +import mock +import StringIO + +from django.core.management.base import CommandError +from django.test import TestCase + +from linkedin.management.commands import LinkedinAPI +from linkedin.models import LinkedInToken + + +class LinkedinAPITests(TestCase): + + def setUp(self): + patcher = mock.patch('linkedin.management.commands.uuid.uuid4') + uuid4 = patcher.start() + uuid4.return_value = '0000-0000' + self.addCleanup(patcher.stop) + + def make_one(self): + return LinkedinAPI(DummyCommand()) + + @mock.patch('django.conf.settings.LINKEDIN_API', None) + def test_ctor_no_api_config(self): + with self.assertRaises(CommandError): + self.make_one() + + def test_ctor_no_token(self): + api = self.make_one() + self.assertEqual(api.token, None) + + def test_ctor_with_token(self): + token = LinkedInToken() + token.save() + api = self.make_one() + self.assertEqual(api.token, token) + + def test_http_error(self): + api = self.make_one() + with self.assertRaises(CommandError): + api.http_error(DummyHTTPError(), "That didn't work") + self.assertEqual( + api.command.stderr.getvalue(), + "!!ERROR!!" + "HTTPError OMG!" + "OMG OHNOES!") + + def test_authorization_url(self): + api = self.make_one() + self.assertEqual( + api.authorization_url(), + 'https://www.linkedin.com/uas/oauth2/authorization?' + 'response_type=code&client_id=12345&state=0000-0000&' + 'redirect_uri=http://bar.foo') + + def test_get_authorization_code(self): + fut = self.make_one().get_authorization_code + self.assertEqual( + fut('http://foo.bar/?state=0000-0000&code=54321'), '54321') + + def test_access_token_url(self): + fut = self.make_one().access_token_url + self.assertEqual( + fut('54321'), + 'https://www.linkedin.com/uas/oauth2/accessToken?' + 'grant_type=authorization_code&code=54321&' + 'redirect_uri=http://bar.foo&client_id=12345&client_secret=SECRET') + + def test_get_access_token(self): + api = self.make_one() + api.call_json_api = mock.Mock(return_value={'access_token': '777'}) + self.assertEqual(api.get_access_token('54321'), '777') + token = LinkedInToken.objects.get() + self.assertEqual(token.access_token, '777') + + def test_get_access_token_overwrite_previous(self): + LinkedInToken(access_token='888').save() + api = self.make_one() + api.call_json_api = mock.Mock(return_value={'access_token': '777'}) + self.assertEqual(api.get_access_token('54321'), '777') + token = LinkedInToken.objects.get() + self.assertEqual(token.access_token, '777') + + def test_require_token_no_token(self): + fut = self.make_one().require_token + with self.assertRaises(CommandError): + fut() + + def test_require_token(self): + LinkedInToken().save() + fut = self.make_one().require_token + fut() + + def test_batch_url(self): + LinkedInToken(access_token='777').save() + fut = self.make_one().batch_url + emails = ['foo@bar', 'bar@foo'] + self.assertEquals( + fut(emails), + 'https://api.linkedin.com/v1/people::(email=foo@bar,email=bar@foo):' + '(id)?oauth2_access_token=777') + + def test_batch(self): + LinkedInToken(access_token='777').save() + api = self.make_one() + api.call_json_api = mock.Mock(return_value={ + 'values': [{'_key': 'email=bar@foo'}]}) + emails = ['foo@bar', 'bar@foo'] + self.assertEqual(list(api.batch(emails)), [False, True]) + + +class DummyCommand(object): + + def __init__(self): + self.stderr = StringIO.StringIO() + + +class DummyHTTPError(object): + + def __str__(self): + return 'HTTPError OMG!' + + def read(self): + return 'OMG OHNOES!' diff --git a/lms/envs/test.py b/lms/envs/test.py index e718cc3f7117..3ec4aaa158a8 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -260,6 +260,9 @@ ############################ LinkedIn Integration ############################# INSTALLED_APPS += ('linkedin',) LINKEDIN_API = { + 'CLIENT_ID': '12345', + 'CLIENT_SECRET': 'SECRET', + 'REDIRECT_URI': 'http://bar.foo', 'COMPANY_NAME': 'edX', 'COMPANY_ID': '0000000', 'EMAIL_FROM': 'The Team ', From f2862965684465ada8a66b76d76b1556028b0ac2 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 20 Dec 2013 11:48:53 -0500 Subject: [PATCH 31/48] pep8 --- .../linkedin/management/commands/linkedin_mailusers.py | 2 +- .../linkedin/management/commands/tests/test_mailusers.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index b02fafeabebd..4bb93333dc8e 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -94,7 +94,7 @@ def certificate_url(self, certificate, grandfather=False): ('_mSplash', '1'), ('trk', tracking_code), ('startTask', 'CERTIFICATION_NAME'), - ('force', 'true'),] + ('force', 'true')] return 'http://www.linkedin.com/profile/guided?' + urllib.urlencode(query) def send_grandfather_email(self, user, certificates): diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index 3f84c4f23b1a..6eafd656a542 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -31,6 +31,7 @@ def setUp(self): 'TEST2': mock.Mock(org='TestX', number='2'), 'TEST3': mock.Mock(org='TestX', number='3'), } + def get_course_by_id(id): return courses.get(id) patcher = mock.patch(MODULE + 'get_course_by_id', get_course_by_id) @@ -159,7 +160,8 @@ def test_certificate_url(self): 2010, 8, 15, 0, 0, tzinfo=utc) self.cert1.save() fut = mailusers.Command().certificate_url - self.assertEqual(fut(self.cert1), + self.assertEqual( + fut(self.cert1), 'http://www.linkedin.com/profile/guided?' 'pfCertificationName=TestX%2FIntro101&pfAuthorityName=edX&' 'pfAuthorityId=0000000&' From c2651497a2263cbfe1dc6229904b3497717138fb Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 20 Dec 2013 11:53:19 -0500 Subject: [PATCH 32/48] Pylint --- .../linkedin/management/commands/__init__.py | 12 ++++++++++++ .../management/commands/linkedin_mailusers.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/linkedin/management/commands/__init__.py b/lms/djangoapps/linkedin/management/commands/__init__.py index 0a97a94823d3..af1b51d4f3b0 100644 --- a/lms/djangoapps/linkedin/management/commands/__init__.py +++ b/lms/djangoapps/linkedin/management/commands/__init__.py @@ -60,6 +60,9 @@ def get_authorization_code(self, redirect): return query['code'][0] def access_token_url(self, code): + """ + Construct URL for retreiving access token, given authorization code. + """ config = self.config return ("https://www.linkedin.com/uas/oauth2/accessToken" "?grant_type=authorization_code" @@ -68,6 +71,9 @@ def access_token_url(self, code): config['CLIENT_SECRET'])) def call_json_api(self, url): + """ + Make an HTTP call to the LinkedIn JSON API. + """ try: request = urllib2.Request(url, headers={'x-li-format': 'json'}) response = urllib2.urlopen(request).read() @@ -92,12 +98,18 @@ def get_access_token(self, code): return access_token def require_token(self): + """ + Raise CommandError if user has not yet obtained an access token. + """ if self.token is None: raise CommandError( "You must log in to LinkedIn in order to use this script. " "Please use the 'login' command to log in to LinkedIn.") def batch_url(self, emails): + """ + Construct URL for querying a batch of email addresses. + """ self.require_token() queries = ','.join(("email=" + email for email in emails)) url = "https://api.linkedin.com/v1/people::(%s):(id)" % queries diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 4bb93333dc8e..e9295dd5884f 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -42,7 +42,7 @@ class Command(BaseCommand): "certificate mail form will be used."),) def __init__(self): - super(BaseCommand, self).__init__() + super(Command, self).__init__() self.api = LinkedinAPI(self) def handle(self, *args, **options): From 33f761134144b5012554242b849b2d882a606144 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 20 Dec 2013 13:31:26 -0500 Subject: [PATCH 33/48] It looks like LinkedIn pfCertFuture as the date a certificate expires. We were using to indicate the date a course ended. Better to remove it, since edX certificates don't really expire. --- .../linkedin/management/commands/linkedin_mailusers.py | 1 - .../linkedin/management/commands/tests/test_mailusers.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index e9295dd5884f..f73e687002a5 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -90,7 +90,6 @@ def certificate_url(self, certificate, grandfather=False): ('pfCertificationUrl', certificate.download_url), ('pfLicenseNo', certificate.course_id), ('pfCertStartDate', course.start.strftime('%Y%mI')), - ('pfCertFuture', certificate.created_date.strftime('%Y%m')), ('_mSplash', '1'), ('trk', tracking_code), ('startTask', 'CERTIFICATION_NAME'), diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index 6eafd656a542..d09865f5140e 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -166,5 +166,5 @@ def test_certificate_url(self): 'pfCertificationName=TestX%2FIntro101&pfAuthorityName=edX&' 'pfAuthorityId=0000000&' 'pfCertificationUrl=http%3A%2F%2Ftest.foo%2Ftest&pfLicenseNo=TEST1&' - 'pfCertStartDate=201005I&pfCertFuture=201008&_mSplash=1&' + 'pfCertStartDate=201005I&_mSplash=1&' 'trk=eml-prof-TestX-1-T&startTask=CERTIFICATION_NAME&force=true') From 1a5eb086c978d67977e2a49c1934a8ebfcdb51bf Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 30 Dec 2013 13:18:14 -0500 Subject: [PATCH 34/48] Remove stray 'I' from generated URL. --- .../linkedin/management/commands/linkedin_mailusers.py | 2 +- .../linkedin/management/commands/tests/test_mailusers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index f73e687002a5..1304d71878f2 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -89,7 +89,7 @@ def certificate_url(self, certificate, grandfather=False): ('pfAuthorityId', self.api.config['COMPANY_ID']), ('pfCertificationUrl', certificate.download_url), ('pfLicenseNo', certificate.course_id), - ('pfCertStartDate', course.start.strftime('%Y%mI')), + ('pfCertStartDate', course.start.strftime('%Y%m')), ('_mSplash', '1'), ('trk', tracking_code), ('startTask', 'CERTIFICATION_NAME'), diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index d09865f5140e..d80627275747 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -166,5 +166,5 @@ def test_certificate_url(self): 'pfCertificationName=TestX%2FIntro101&pfAuthorityName=edX&' 'pfAuthorityId=0000000&' 'pfCertificationUrl=http%3A%2F%2Ftest.foo%2Ftest&pfLicenseNo=TEST1&' - 'pfCertStartDate=201005I&_mSplash=1&' + 'pfCertStartDate=201005&_mSplash=1&' 'trk=eml-prof-TestX-1-T&startTask=CERTIFICATION_NAME&force=true') From a18bce81f294008bbcf3a4ead914da2645cb2fae Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 9 Jan 2014 00:36:16 -0500 Subject: [PATCH 35/48] Basic cleanup of code to determine whether a user has a LinkedIn account. --- common/djangoapps/util/models.py | 1 + .../linkedin/management/commands/__init__.py | 12 +++- .../management/commands/linkedin_findusers.py | 68 +++++++++++-------- .../management/commands/linkedin_login.py | 4 +- .../management/commands/linkedin_mailusers.py | 4 +- .../management/commands/tests/test_api.py | 6 +- .../commands/tests/test_findusers.py | 11 +-- lms/envs/test.py | 1 + 8 files changed, 63 insertions(+), 44 deletions(-) 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/linkedin/management/commands/__init__.py b/lms/djangoapps/linkedin/management/commands/__init__.py index af1b51d4f3b0..3696e8e28f46 100644 --- a/lms/djangoapps/linkedin/management/commands/__init__.py +++ b/lms/djangoapps/linkedin/management/commands/__init__.py @@ -8,11 +8,14 @@ from django.conf import settings from django.core.management.base import CommandError +import requests from ...models import LinkedInToken +class LinkedInError(Exception): + pass -class LinkedinAPI(object): +class LinkedInAPI(object): """ Encapsulates the LinkedIn API. """ @@ -74,9 +77,14 @@ def call_json_api(self, url): """ Make an HTTP call to the LinkedIn JSON API. """ + if settings.LINKEDIN_API.get('TEST_MODE'): + raise LinkedInError( + "Attempting to make real API call while in test mode - " + "Mock LinkedInAPI.call_json_api instead." + ) try: request = urllib2.Request(url, headers={'x-li-format': 'json'}) - response = urllib2.urlopen(request).read() + response = urllib2.urlopen(request, timeout=5).read() return json.loads(response) except urllib2.HTTPError, error: self.http_error(error, "Error calling LinkedIn API") diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py index 520b89168e1a..95ea23b5a334 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py @@ -12,13 +12,14 @@ from optparse import make_option -from ...models import LinkedIn -from . import LinkedinAPI +from util.query import use_read_replica_if_available +from linkedin.models import LinkedIn +from . import LinkedInAPI FRIDAY = 4 -def get_call_limits(): +def get_call_limits(force_unlimited=False): """ Returns a tuple of: (max_checks, checks_per_call, time_between_calls) @@ -40,7 +41,7 @@ def get_call_limits(): lastfriday -= datetime.timedelta(days=1) safeharbor_begin = lastfriday.replace(hour=18, minute=0) safeharbor_end = safeharbor_begin + datetime.timedelta(days=2, hours=11) - if safeharbor_begin < now < safeharbor_end: + if force_unlimited or (safeharbor_begin < now < safeharbor_end): return -1, 80, 1 elif now.hour >= 18 or now.hour < 5: return 500, 80, 1 @@ -62,33 +63,38 @@ class Command(BaseCommand): dest='recheck', default=False, help='Check users that have been checked in the past to see if ' - 'they have joined or left LinkedIn since the last check'), + 'they have joined or left LinkedIn since the last check' + ), make_option( '--force', action='store_true', dest='force', default=False, help='Disregard the parameters provided by LinkedIn about when it ' - 'is appropriate to make API calls.')) + 'is appropriate to make API calls.' + ) + ) def handle(self, *args, **options): """ Check users. """ - api = LinkedinAPI(self) - recheck = options.pop('recheck', False) - force = options.pop('force', False) - if force: - max_checks, checks_per_call, time_between_calls = -1, 80, 1 - else: - max_checks, checks_per_call, time_between_calls = get_call_limits() - if not max_checks: - raise CommandError("No checks allowed during this time.") - - def batch_users(): - "Generator to lazily generate batches of users to query." + api = LinkedInAPI(self) + recheck = options.get('recheck', False) + force = options.get('force', False) + max_checks, checks_per_call, time_between_calls = get_call_limits(force) + + if not max_checks: + raise CommandError("No checks allowed during this time.") + + def user_batches_to_check(): + """Generate batches of users we should query against LinkedIn.""" count = 0 batch = [] + + users = use_read_replica_if_available( + None + ) for user in User.objects.all(): if not hasattr(user, 'linkedin'): LinkedIn(user=user).save() @@ -98,8 +104,9 @@ def batch_users(): if len(batch) == checks_per_call: yield batch batch = [] + count += 1 - if max_checks != 1 and count == max_checks: + if max_checks != -1 and count >= max_checks: self.stderr.write( "WARNING: limited to checking only %d users today." % max_checks) @@ -107,20 +114,21 @@ def batch_users(): if batch: yield batch - def do_batch(batch): - "Process a batch of users." - emails = (u.email for u in batch) - for user, has_account in zip(batch, api.batch(emails)): + def update_linkedin_account_status(users): + """ + Given a an iterable of User objects, check their email addresses + to see if they have LinkedIn email addresses and save that + information to our database. + """ + emails = (u.email for u in users) + for user, has_account in zip(users, api.batch(emails)): linkedin = user.linkedin if linkedin.has_linkedin_account != has_account: linkedin.has_linkedin_account = has_account linkedin.save() - batches = batch_users() - try: - do_batch(batches.next()) # may raise StopIteration - for batch in batches: + for i, user_batch in enumerate(user_batches_to_check()): + if i > 0: + # Sleep between LinkedIn API web service calls time.sleep(time_between_calls) - do_batch(batch) - except StopIteration: - pass + update_linkedin_account_status(user_batch) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_login.py b/lms/djangoapps/linkedin/management/commands/linkedin_login.py index b2df0112c38b..4ec13dd9e287 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_login.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_login.py @@ -3,7 +3,7 @@ """ from django.core.management.base import BaseCommand -from . import LinkedinAPI +from . import LinkedInAPI class Command(BaseCommand): @@ -19,7 +19,7 @@ class Command(BaseCommand): def handle(self, *args, **options): """ """ - api = LinkedinAPI(self) + api = LinkedInAPI(self) print "Let's log into your LinkedIn account." print "Start by visiting this url:" print api.authorization_url() diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 1304d71878f2..9707e343f6c5 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -16,7 +16,7 @@ from courseware.courses import get_course_by_id from ...models import LinkedIn -from . import LinkedinAPI +from . import LinkedInAPI class Command(BaseCommand): @@ -43,7 +43,7 @@ class Command(BaseCommand): def __init__(self): super(Command, self).__init__() - self.api = LinkedinAPI(self) + self.api = LinkedInAPI(self) def handle(self, *args, **options): whitelist = self.api.config.get('EMAIL_WHITELIST') diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_api.py b/lms/djangoapps/linkedin/management/commands/tests/test_api.py index 8dc2915a48c3..62d2d3eab1c4 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_api.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_api.py @@ -4,11 +4,11 @@ from django.core.management.base import CommandError from django.test import TestCase -from linkedin.management.commands import LinkedinAPI +from linkedin.management.commands import LinkedInAPI from linkedin.models import LinkedInToken -class LinkedinAPITests(TestCase): +class LinkedInAPITests(TestCase): def setUp(self): patcher = mock.patch('linkedin.management.commands.uuid.uuid4') @@ -17,7 +17,7 @@ def setUp(self): self.addCleanup(patcher.stop) def make_one(self): - return LinkedinAPI(DummyCommand()) + return LinkedInAPI(DummyCommand()) @mock.patch('django.conf.settings.LINKEDIN_API', None) def test_ctor_no_api_config(self): diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py index 984ee5cd0883..ae05c8161c93 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py @@ -69,7 +69,7 @@ def test_get_call_limits_on_weeknights(self, timezone): @mock.patch(MODULE + 'time') @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'LinkedInAPI') @mock.patch(MODULE + 'get_call_limits') def test_command_success_recheck_no_limits(self, get_call_limits, apicls, usercls, time): @@ -93,7 +93,7 @@ def dummy_batch(emails): @mock.patch(MODULE + 'time') @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'LinkedInAPI') @mock.patch(MODULE + 'get_call_limits') def test_command_success_no_recheck_no_limits(self, get_call_limits, apicls, usercls, time): @@ -123,7 +123,7 @@ def dummy_batch(emails): @mock.patch(MODULE + 'time') @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'LinkedInAPI') @mock.patch(MODULE + 'get_call_limits') def test_command_success_no_recheck_no_users(self, get_call_limits, apicls, usercls, time): @@ -149,7 +149,7 @@ def dummy_batch(_): @mock.patch(MODULE + 'time') @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'LinkedInAPI') @mock.patch(MODULE + 'get_call_limits') def test_command_success_recheck_with_limit(self, get_call_limits, apicls, usercls, time): @@ -178,7 +178,7 @@ def dummy_batch(emails): self.assertTrue(command.stderr.getvalue().startswith("WARNING")) @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedinAPI') + @mock.patch(MODULE + 'LinkedInAPI') @mock.patch(MODULE + 'get_call_limits') def test_command_success_recheck_with_force(self, get_call_limits, apicls, usercls): @@ -199,6 +199,7 @@ def dummy_batch(emails): "Mock LinkedIn API." return [email % 2 == 0 for email in emails] api.batch = dummy_batch + get_call_limits.return_value = (-1, 80, 1) fut(force=True) self.assertEqual([u.linkedin.has_linkedin_account for u in users], [i % 2 == 0 for i in xrange(10)]) diff --git a/lms/envs/test.py b/lms/envs/test.py index 3ec4aaa158a8..a5d8b182ff2e 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -266,6 +266,7 @@ 'COMPANY_NAME': 'edX', 'COMPANY_ID': '0000000', 'EMAIL_FROM': 'The Team ', + 'TEST_MODE': True } ################### Make tests faster From a3f1f6600d7746b6c03cab9b0a60456135800c30 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 9 Jan 2014 18:24:53 -0500 Subject: [PATCH 36/48] Integrate new LinkedIn e-mail template --- .../management/commands/linkedin_mailusers.py | 40 +- .../commands/tests/test_mailusers.py | 61 +- .../templates/linkedin_grandfather_email.html | 28 - lms/templates/linkedin/linkedin_email.html | 836 ++++++++++++++++++ 4 files changed, 898 insertions(+), 67 deletions(-) delete mode 100644 lms/djangoapps/linkedin/templates/linkedin_grandfather_email.html create mode 100644 lms/templates/linkedin/linkedin_email.html diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 9707e343f6c5..e6e8571d9b89 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -6,14 +6,18 @@ import json import urllib +from django.conf import settings from django.core.mail import send_mail from django.core.management.base import BaseCommand 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 +from courseware.courses import get_course_by_id, course_image_url from ...models import LinkedIn from . import LinkedInAPI @@ -101,15 +105,31 @@ def send_grandfather_email(self, user, certificates): Send the 'grandfathered' email informing historical students that they may now post their certificates on their LinkedIn profiles. """ - template = get_template("linkedin_grandfather_email.html") - links = [ - {'course_name': certificate.name, - 'url': self.certificate_url(certificate, grandfather=True)} - for certificate in certificates] - context = Context({ - 'student_name': user.profile.name, - 'certificates': links}) - body = template.render(context) + 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 + + course_img_url = 'https://{}{}'.format(settings.SITE_NAME, course_image_url(course)) + course_end_date = course.end.strftime('%b %Y') + course_org = course.display_organization + + 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 = 'Congratulations! Put your certificates on LinkedIn' self.send_email(user, subject, body) diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index d80627275747..f766162f0ad1 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -7,36 +7,39 @@ 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 linkedin.models import LinkedIn +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config from linkedin.management.commands import linkedin_mailusers as mailusers 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): - courses = { - 'TEST1': mock.Mock( - org='TestX', number='1', - start=datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc)), - 'TEST2': mock.Mock(org='TestX', number='2'), - 'TEST3': mock.Mock(org='TestX', number='3'), - } - - def get_course_by_id(id): - return courses.get(id) - patcher = mock.patch(MODULE + 'get_course_by_id', get_course_by_id) - patcher.start() - self.addCleanup(patcher.stop) + 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() @@ -51,19 +54,19 @@ def get_course_by_id(id): self.cert1 = cert1 = GeneratedCertificate( status='downloadable', user=fred, - course_id='TEST1', + course_id='TESTX/1/TEST1', name='TestX/Intro101', download_url='http://test.foo/test') cert1.save() cert2 = GeneratedCertificate( status='downloadable', user=fred, - course_id='TEST2') + course_id='TESTX/2/TEST2') cert2.save() cert3 = GeneratedCertificate( status='downloadable', user=barney, - course_id='TEST3') + course_id='TESTX/3/TEST3') cert3.save() def test_mail_users(self): @@ -73,9 +76,9 @@ def test_mail_users(self): fut = mailusers.Command().handle fut() self.assertEqual( - json.loads(self.fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) + json.loads(self.fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2']) self.assertEqual( - json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) + json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3']) self.assertEqual(len(mail.outbox), 3) self.assertEqual(mail.outbox[0].from_email, 'The Team ') self.assertEqual( @@ -94,7 +97,7 @@ def test_mail_users_with_whitelist(self): fut = mailusers.Command().handle fut() self.assertEqual( - json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) + json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3']) self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].to, ['Barney Rubble ']) @@ -106,9 +109,9 @@ def test_mail_users_grandfather(self): fut = mailusers.Command().handle fut(grandfather=True) self.assertEqual( - json.loads(self.fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) + json.loads(self.fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2']) self.assertEqual( - json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) + json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3']) self.assertEqual(len(mail.outbox), 2) self.assertEqual( mail.outbox[0].to, ['Fred Flintstone ']) @@ -120,15 +123,15 @@ def test_mail_users_only_new_courses(self): Test emailing users, making sure they are only emailed about new certificates. """ - self.fred.linkedin.emailed_courses = json.dumps(['TEST1']) + self.fred.linkedin.emailed_courses = json.dumps(['TESTX/1/TEST1']) self.fred.linkedin.save() fut = mailusers.Command().handle fut() fred = User.objects.get(username='fred') self.assertEqual( - json.loads(fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) + json.loads(fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2']) self.assertEqual( - json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) + json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3']) self.assertEqual(len(mail.outbox), 2) self.assertEqual( mail.outbox[0].to, ['Fred Flintstone ']) @@ -140,15 +143,15 @@ def test_mail_users_barney_has_no_new_courses(self): Test emailing users, making sure they are only emailed about new certificates. """ - self.barney.linkedin.emailed_courses = json.dumps(['TEST3']) + self.barney.linkedin.emailed_courses = json.dumps(['TESTX/3/TEST3']) self.barney.linkedin.save() fut = mailusers.Command().handle fut() fred = User.objects.get(username='fred') self.assertEqual( - json.loads(fred.linkedin.emailed_courses), ['TEST1', 'TEST2']) + json.loads(fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2']) self.assertEqual( - json.loads(self.barney.linkedin.emailed_courses), ['TEST3']) + json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3']) self.assertEqual(len(mail.outbox), 2) self.assertEqual( mail.outbox[0].to, ['Fred Flintstone ']) @@ -165,6 +168,6 @@ def test_certificate_url(self): 'http://www.linkedin.com/profile/guided?' 'pfCertificationName=TestX%2FIntro101&pfAuthorityName=edX&' 'pfAuthorityId=0000000&' - 'pfCertificationUrl=http%3A%2F%2Ftest.foo%2Ftest&pfLicenseNo=TEST1&' + 'pfCertificationUrl=http%3A%2F%2Ftest.foo%2Ftest&pfLicenseNo=TESTX%2F1%2FTEST1&' 'pfCertStartDate=201005&_mSplash=1&' - 'trk=eml-prof-TestX-1-T&startTask=CERTIFICATION_NAME&force=true') + 'trk=eml-prof-TESTX-1-T&startTask=CERTIFICATION_NAME&force=true') diff --git a/lms/djangoapps/linkedin/templates/linkedin_grandfather_email.html b/lms/djangoapps/linkedin/templates/linkedin_grandfather_email.html deleted file mode 100644 index cc373a3d8ed9..000000000000 --- a/lms/djangoapps/linkedin/templates/linkedin_grandfather_email.html +++ /dev/null @@ -1,28 +0,0 @@ -{% load i18n %} - - - - - - - -

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

- -

{% blocktrans with name=course_name %} - We've partnered with LinkedIn and now you can put your certificates on - your LinkedIn profile. Just use the links below. - {% endblocktrans %}

- - {% for cert in certificates %} -

- {{cert.course_name}}: - - in - {% blocktrans %}Add to profile{% endblocktrans %} - -

- {% endfor %} - - diff --git a/lms/templates/linkedin/linkedin_email.html b/lms/templates/linkedin/linkedin_email.html new file mode 100644 index 000000000000..22b0f799e9fc --- /dev/null +++ b/lms/templates/linkedin/linkedin_email.html @@ -0,0 +1,836 @@ + + + + + +## 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 // + + + + + + + +
+ + + + + +
+ + + + + + +
+ +%if num_courses==1: + Through a partnership with LinkedIn, the world's largest professional network, we've now made it even easier for you to showcase your success. We encourage you to share your edX certificate on your LinkedIn profile. Simply click the "Add to profile" button below. +%else: + Through a partnership with LinkedIn, the world's largest professional network, we've now made it even easier for you to showcase your success. We encourage you to share your edX certificates on your LinkedIn profile. Simply click the "Add to profile" buttons below. +%endif +
+ +
+ + +%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}
+
+ +## TODO put path/to/real/source/file here +
+
+
+
+ +## 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 +
+ + + From f37001275abe8deaf41c02c2a22d27dda5fc9a0b Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 10 Jan 2014 10:54:10 -0500 Subject: [PATCH 37/48] Clean up the e-mail settings. --- .../linkedin/management/commands/linkedin_mailusers.py | 4 ++-- .../linkedin/management/commands/tests/test_mailusers.py | 4 ++-- lms/templates/linkedin/linkedin_email.html | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index e6e8571d9b89..f36c9ea704ad 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -89,7 +89,7 @@ def certificate_url(self, certificate, grandfather=False): 'gf' if grandfather else 'T']) query = [ ('pfCertificationName', certificate.name), - ('pfAuthorityName', self.api.config['COMPANY_NAME']), + ('pfAuthorityName', settings.PLATFORM_NAME), ('pfAuthorityId', self.api.config['COMPANY_ID']), ('pfCertificationUrl', certificate.download_url), ('pfLicenseNo', certificate.course_id), @@ -152,6 +152,6 @@ def send_email(self, user, subject, body): """ Send an email. """ - fromaddr = self.api.config['EMAIL_FROM'] + fromaddr = settings.DEFAULT_FROM_EMAIL toaddr = '%s <%s>' % (user.profile.name, user.email) send_mail(subject, body, fromaddr, (toaddr,)) diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index f766162f0ad1..d1ada3663e37 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -16,7 +16,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from student.models import UserProfile from linkedin.models import LinkedIn -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config +from xmodule.modulestore.tests.django_utils import mixed_store_config from linkedin.management.commands import linkedin_mailusers as mailusers MODULE = 'linkedin.management.commands.linkedin_mailusers.' @@ -80,7 +80,7 @@ def test_mail_users(self): self.assertEqual( json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3']) self.assertEqual(len(mail.outbox), 3) - self.assertEqual(mail.outbox[0].from_email, 'The Team ') + self.assertEqual(mail.outbox[0].from_email, settings.DEFAULT_FROM_EMAIL) self.assertEqual( mail.outbox[0].to, ['Fred Flintstone ']) self.assertEqual( diff --git a/lms/templates/linkedin/linkedin_email.html b/lms/templates/linkedin/linkedin_email.html index 22b0f799e9fc..b5bf140a949b 100644 --- a/lms/templates/linkedin/linkedin_email.html +++ b/lms/templates/linkedin/linkedin_email.html @@ -643,7 +643,7 @@
## TODO put path/to/real/source/file here -
+
From ed648176445c0dea4b1bb950eb6ee39d144ff55b Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 10 Jan 2014 13:07:46 -0500 Subject: [PATCH 38/48] Use correct linkedin button. --- lms/static/images/linkedin_add_to_profile.png | Bin 0 -> 1119 bytes lms/templates/linkedin/linkedin_email.html | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 lms/static/images/linkedin_add_to_profile.png 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 0000000000000000000000000000000000000000..d72b680f49ebdac14c2890d8392f5e839a27bd70 GIT binary patch literal 1119 zcmV-l1fctgP)Px#1ZP1_K>z@;j|==^1poj92}wjjRCr$PoV|`5MHEK&g)GN@0Ox)IzW^_QkjW64 zBS?`5oI>UVv;lzw2M$PViNHe0vLz>$Ab@~i*fVg<=c}4_O}h~@t9zxR?pr^%s&9Q& z-MhhDU^hPb?#|77mp|Y9^m5lUi(Kghz5FYjQKP0=U@xFPahg?J>E>cs(KHjZc$(F; zc$(F;c$(ELis#v@-*#IX@kZk`=gFdY{tFw8)0`(Qo@O=E;`#Q;%iZ0tp6st5Vxw`I z^JGdqFW&tAPC5Qs%Q($>GbNs1|M+uv>$4yBWnA&sS{p)X`7lp1#)ee8$Q0ig7q8bL{NHz40OI>CcHvKd$&0 zSL2?tj77Uq4l*XsVfnFaA|58j17{nl0vffMft`hM^#sZXCt3L|#=Sx_akNj%lgv0m zpK2tV#E)LxEJlryaq4#>zVjz8My_l}KLj`GNBL-elo^XFv8#ASF?k+Ec?_F~2iyse z6R8W9eU3pP@bCx~`~K*=FWuI9Tph;tqG==K;DO^to=D;Fjlvz(( z&9aK7KN`nVadi6l{5dYTh&+o6n44iVU+|d}s%HI?c#O%|)Pr+RLC&&?cyci{>vDlG z%a6g`&I6JR6N34=)UsbD&PAjw+h|#qXoq~L3zi%jH|I9$KhqCiv%bd4_QqrbP+l>{ zESsG?)ehBV`3xz(d{B3wJ5!(CWnXtUNi$BKiYa=_Dw=+t7?vsSrIV-b?2I!Kk8@k< zhp$<`Bp!26V^WuSo8{!iGy1-P6crC{(hw2J(8uuUV%803D>_qcV;X01|l;pD}0EC|Fz z9fCz$b4({J0^__dj^bg#@Yjs(Gx@680%ML&%UQ0ScutKfp6kT4c($_9IM*3Y8HD4j z^*YhwX;!l+p8UYu!iYB-r#Vj+#nUuPw0N4;w0N4;w0N4;^si(z>)=;1`uBO7HN5)+ lPkjP4GhFH9Z{b{A`~@%^I76kJX;=UN002ovPDHLkV1hM*FbV(w literal 0 HcmV?d00001 diff --git a/lms/templates/linkedin/linkedin_email.html b/lms/templates/linkedin/linkedin_email.html index b5bf140a949b..2fe663b9b6bd 100644 --- a/lms/templates/linkedin/linkedin_email.html +++ b/lms/templates/linkedin/linkedin_email.html @@ -1,3 +1,4 @@ +<%namespace name='static' file='../static_content.html'/> @@ -643,7 +644,7 @@
## TODO put path/to/real/source/file here -
+
From db7308adc11cf326aed96c493440e30d30f31836 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 13 Jan 2014 14:43:54 -0500 Subject: [PATCH 39/48] Remove unused parts of LinkedIn API Fix whitelist logic to handle empty lists. --- .../linkedin/management/commands/__init__.py | 134 ----------- .../management/commands/linkedin_findusers.py | 134 ----------- .../management/commands/linkedin_login.py | 31 --- .../management/commands/linkedin_mailusers.py | 9 +- .../management/commands/tests/test_api.py | 123 ---------- .../commands/tests/test_findusers.py | 216 ------------------ .../commands/tests/test_mailusers.py | 2 +- lms/djangoapps/linkedin/models.py | 8 - lms/envs/common.py | 15 ++ lms/envs/test.py | 15 +- 10 files changed, 23 insertions(+), 664 deletions(-) delete mode 100644 lms/djangoapps/linkedin/management/commands/linkedin_findusers.py delete mode 100644 lms/djangoapps/linkedin/management/commands/linkedin_login.py delete mode 100644 lms/djangoapps/linkedin/management/commands/tests/test_api.py delete mode 100644 lms/djangoapps/linkedin/management/commands/tests/test_findusers.py diff --git a/lms/djangoapps/linkedin/management/commands/__init__.py b/lms/djangoapps/linkedin/management/commands/__init__.py index 3696e8e28f46..e69de29bb2d1 100644 --- a/lms/djangoapps/linkedin/management/commands/__init__.py +++ b/lms/djangoapps/linkedin/management/commands/__init__.py @@ -1,134 +0,0 @@ -""" -Class for accessing LinkedIn's API. -""" -import json -import urllib2 -import urlparse -import uuid - -from django.conf import settings -from django.core.management.base import CommandError -import requests - -from ...models import LinkedInToken - -class LinkedInError(Exception): - pass - -class LinkedInAPI(object): - """ - Encapsulates the LinkedIn API. - """ - def __init__(self, command): - config = getattr(settings, "LINKEDIN_API", None) - if not config: - raise CommandError("LINKEDIN_API is not configured") - self.config = config - - try: - self.token = LinkedInToken.objects.get() - except LinkedInToken.DoesNotExist: - self.token = None - - self.command = command - self.state = str(uuid.uuid4()) - - def http_error(self, error, message): - """ - Handle an unexpected HTTP response. - """ - stderr = self.command.stderr - stderr.write("!!ERROR!!") - stderr.write(error) - stderr.write(error.read()) - raise CommandError(message) - - def authorization_url(self): - """ - Synthesize a URL for beginning the authorization flow. - """ - config = self.config - return ("https://www.linkedin.com/uas/oauth2/authorization" - "?response_type=code" - "&client_id=%s&state=%s&redirect_uri=%s" % ( - config['CLIENT_ID'], self.state, config['REDIRECT_URI'])) - - def get_authorization_code(self, redirect): - """ - Extract the authorization code from the redirect URL at the end of - the authorization flow. - """ - query = urlparse.parse_qs(urlparse.urlparse(redirect).query) - assert query['state'][0] == self.state, (query['state'][0], self.state) - return query['code'][0] - - def access_token_url(self, code): - """ - Construct URL for retreiving access token, given authorization code. - """ - config = self.config - return ("https://www.linkedin.com/uas/oauth2/accessToken" - "?grant_type=authorization_code" - "&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s" % ( - code, config['REDIRECT_URI'], config['CLIENT_ID'], - config['CLIENT_SECRET'])) - - def call_json_api(self, url): - """ - Make an HTTP call to the LinkedIn JSON API. - """ - if settings.LINKEDIN_API.get('TEST_MODE'): - raise LinkedInError( - "Attempting to make real API call while in test mode - " - "Mock LinkedInAPI.call_json_api instead." - ) - try: - request = urllib2.Request(url, headers={'x-li-format': 'json'}) - response = urllib2.urlopen(request, timeout=5).read() - return json.loads(response) - except urllib2.HTTPError, error: - self.http_error(error, "Error calling LinkedIn API") - - def get_access_token(self, code): - """ - Given an authorization code, get an access token. - """ - response = self.call_json_api(self.access_token_url(code)) - access_token = response['access_token'] - try: - token = LinkedInToken.objects.get() - token.access_token = access_token - except LinkedInToken.DoesNotExist: - token = LinkedInToken(access_token=access_token) - token.save() - self.token = token - - return access_token - - def require_token(self): - """ - Raise CommandError if user has not yet obtained an access token. - """ - if self.token is None: - raise CommandError( - "You must log in to LinkedIn in order to use this script. " - "Please use the 'login' command to log in to LinkedIn.") - - def batch_url(self, emails): - """ - Construct URL for querying a batch of email addresses. - """ - self.require_token() - queries = ','.join(("email=" + email for email in emails)) - url = "https://api.linkedin.com/v1/people::(%s):(id)" % queries - url += "?oauth2_access_token=%s" % self.token.access_token - return url - - def batch(self, emails): - """ - Get the LinkedIn status for a batch of emails. - """ - emails = list(emails) # realize generator since we traverse twice - response = self.call_json_api(self.batch_url(emails)) - accounts = set(value['_key'][6:] for value in response['values']) - return (email in accounts for email in emails) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py deleted file mode 100644 index 95ea23b5a334..000000000000 --- a/lms/djangoapps/linkedin/management/commands/linkedin_findusers.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Provides a command to use with Django's `manage.py` that uses LinkedIn's API to -find edX users that are also users on LinkedIn. -""" -import datetime -import pytz -import time - -from django.contrib.auth.models import User -from django.core.management.base import BaseCommand, CommandError -from django.utils import timezone - -from optparse import make_option - -from util.query import use_read_replica_if_available -from linkedin.models import LinkedIn -from . import LinkedInAPI - -FRIDAY = 4 - - -def get_call_limits(force_unlimited=False): - """ - Returns a tuple of: (max_checks, checks_per_call, time_between_calls) - - Here are the parameters provided by LinkedIn: - - Please note: in order to ensure a successful call, please run the calls - between Friday 6pm PST and Monday 5am PST. - - During the week, calls are limited to very low volume (500 profiles/day) - and must be run after 6pm and before 5am. This should only be used to do - subsequent trigger emails. Please contact the developer support alias for - more information. - - Use 80 emails per API call and 1 call per second. - """ - now = timezone.now().astimezone(pytz.timezone('US/Pacific')) - lastfriday = now - while lastfriday.weekday() != FRIDAY: - lastfriday -= datetime.timedelta(days=1) - safeharbor_begin = lastfriday.replace(hour=18, minute=0) - safeharbor_end = safeharbor_begin + datetime.timedelta(days=2, hours=11) - if force_unlimited or (safeharbor_begin < now < safeharbor_end): - return -1, 80, 1 - elif now.hour >= 18 or now.hour < 5: - return 500, 80, 1 - else: - return 0, 0, 0 - - -class Command(BaseCommand): - """ - Provides a command to use with Django's `manage.py` that uses LinkedIn's - API to find edX users that are also users on LinkedIn. - """ - args = '' - help = 'Checks LinkedIn for students that are on LinkedIn' - option_list = BaseCommand.option_list + ( - make_option( - '--recheck', - action='store_true', - dest='recheck', - default=False, - help='Check users that have been checked in the past to see if ' - 'they have joined or left LinkedIn since the last check' - ), - make_option( - '--force', - action='store_true', - dest='force', - default=False, - help='Disregard the parameters provided by LinkedIn about when it ' - 'is appropriate to make API calls.' - ) - ) - - def handle(self, *args, **options): - """ - Check users. - """ - api = LinkedInAPI(self) - recheck = options.get('recheck', False) - force = options.get('force', False) - max_checks, checks_per_call, time_between_calls = get_call_limits(force) - - if not max_checks: - raise CommandError("No checks allowed during this time.") - - def user_batches_to_check(): - """Generate batches of users we should query against LinkedIn.""" - count = 0 - batch = [] - - users = use_read_replica_if_available( - None - ) - for user in User.objects.all(): - if not hasattr(user, 'linkedin'): - LinkedIn(user=user).save() - checked = user.linkedin.has_linkedin_account is not None - if recheck or not checked: - batch.append(user) - if len(batch) == checks_per_call: - yield batch - batch = [] - - count += 1 - if max_checks != -1 and count >= max_checks: - self.stderr.write( - "WARNING: limited to checking only %d users today." - % max_checks) - break - if batch: - yield batch - - def update_linkedin_account_status(users): - """ - Given a an iterable of User objects, check their email addresses - to see if they have LinkedIn email addresses and save that - information to our database. - """ - emails = (u.email for u in users) - for user, has_account in zip(users, api.batch(emails)): - linkedin = user.linkedin - if linkedin.has_linkedin_account != has_account: - linkedin.has_linkedin_account = has_account - linkedin.save() - - for i, user_batch in enumerate(user_batches_to_check()): - if i > 0: - # Sleep between LinkedIn API web service calls - time.sleep(time_between_calls) - update_linkedin_account_status(user_batch) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_login.py b/lms/djangoapps/linkedin/management/commands/linkedin_login.py deleted file mode 100644 index 4ec13dd9e287..000000000000 --- a/lms/djangoapps/linkedin/management/commands/linkedin_login.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Log into LinkedIn API. -""" -from django.core.management.base import BaseCommand - -from . import LinkedInAPI - - -class Command(BaseCommand): - """ - Can take a sysadmin through steps to log into LinkedIn API so that the - findusers script can work. - """ - args = '' - help = ('Takes a user through the steps to log in to LinkedIn as a user ' - 'with API access in order to gain an access token for use by the ' - 'findusers script.') - - def handle(self, *args, **options): - """ - """ - api = LinkedInAPI(self) - print "Let's log into your LinkedIn account." - print "Start by visiting this url:" - print api.authorization_url() - print - print "Within 30 seconds of logging in, enter the full URL of the " - print "webpage you were redirected to: " - redirect = raw_input() - code = api.get_authorization_code(redirect) - api.get_access_token(code) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index f36c9ea704ad..32d04cd89819 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -20,7 +20,6 @@ from courseware.courses import get_course_by_id, course_image_url from ...models import LinkedIn -from . import LinkedInAPI class Command(BaseCommand): @@ -47,15 +46,14 @@ class Command(BaseCommand): def __init__(self): super(Command, self).__init__() - self.api = LinkedInAPI(self) def handle(self, *args, **options): - whitelist = self.api.config.get('EMAIL_WHITELIST') + whitelist = settings.LINKEDIN_API['EMAIL_WHITELIST'] grandfather = options.get('grandfather', False) accounts = LinkedIn.objects.filter(has_linkedin_account=True) for account in accounts: user = account.user - if whitelist is not None and user.email not in whitelist: + if whitelist and user.email not in whitelist: # Whitelist only certain addresses for testing purposes continue emailed = json.loads(account.emailed_courses) @@ -63,6 +61,7 @@ def handle(self, *args, **options): certificates = certificates.filter(status='downloadable') certificates = [cert for cert in certificates if cert.course_id not in emailed] + if not certificates: continue if grandfather: @@ -90,7 +89,7 @@ def certificate_url(self, certificate, grandfather=False): query = [ ('pfCertificationName', certificate.name), ('pfAuthorityName', settings.PLATFORM_NAME), - ('pfAuthorityId', self.api.config['COMPANY_ID']), + ('pfAuthorityId', settings.LINKEDIN_API['COMPANY_ID']), ('pfCertificationUrl', certificate.download_url), ('pfLicenseNo', certificate.course_id), ('pfCertStartDate', course.start.strftime('%Y%m')), diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_api.py b/lms/djangoapps/linkedin/management/commands/tests/test_api.py deleted file mode 100644 index 62d2d3eab1c4..000000000000 --- a/lms/djangoapps/linkedin/management/commands/tests/test_api.py +++ /dev/null @@ -1,123 +0,0 @@ -import mock -import StringIO - -from django.core.management.base import CommandError -from django.test import TestCase - -from linkedin.management.commands import LinkedInAPI -from linkedin.models import LinkedInToken - - -class LinkedInAPITests(TestCase): - - def setUp(self): - patcher = mock.patch('linkedin.management.commands.uuid.uuid4') - uuid4 = patcher.start() - uuid4.return_value = '0000-0000' - self.addCleanup(patcher.stop) - - def make_one(self): - return LinkedInAPI(DummyCommand()) - - @mock.patch('django.conf.settings.LINKEDIN_API', None) - def test_ctor_no_api_config(self): - with self.assertRaises(CommandError): - self.make_one() - - def test_ctor_no_token(self): - api = self.make_one() - self.assertEqual(api.token, None) - - def test_ctor_with_token(self): - token = LinkedInToken() - token.save() - api = self.make_one() - self.assertEqual(api.token, token) - - def test_http_error(self): - api = self.make_one() - with self.assertRaises(CommandError): - api.http_error(DummyHTTPError(), "That didn't work") - self.assertEqual( - api.command.stderr.getvalue(), - "!!ERROR!!" - "HTTPError OMG!" - "OMG OHNOES!") - - def test_authorization_url(self): - api = self.make_one() - self.assertEqual( - api.authorization_url(), - 'https://www.linkedin.com/uas/oauth2/authorization?' - 'response_type=code&client_id=12345&state=0000-0000&' - 'redirect_uri=http://bar.foo') - - def test_get_authorization_code(self): - fut = self.make_one().get_authorization_code - self.assertEqual( - fut('http://foo.bar/?state=0000-0000&code=54321'), '54321') - - def test_access_token_url(self): - fut = self.make_one().access_token_url - self.assertEqual( - fut('54321'), - 'https://www.linkedin.com/uas/oauth2/accessToken?' - 'grant_type=authorization_code&code=54321&' - 'redirect_uri=http://bar.foo&client_id=12345&client_secret=SECRET') - - def test_get_access_token(self): - api = self.make_one() - api.call_json_api = mock.Mock(return_value={'access_token': '777'}) - self.assertEqual(api.get_access_token('54321'), '777') - token = LinkedInToken.objects.get() - self.assertEqual(token.access_token, '777') - - def test_get_access_token_overwrite_previous(self): - LinkedInToken(access_token='888').save() - api = self.make_one() - api.call_json_api = mock.Mock(return_value={'access_token': '777'}) - self.assertEqual(api.get_access_token('54321'), '777') - token = LinkedInToken.objects.get() - self.assertEqual(token.access_token, '777') - - def test_require_token_no_token(self): - fut = self.make_one().require_token - with self.assertRaises(CommandError): - fut() - - def test_require_token(self): - LinkedInToken().save() - fut = self.make_one().require_token - fut() - - def test_batch_url(self): - LinkedInToken(access_token='777').save() - fut = self.make_one().batch_url - emails = ['foo@bar', 'bar@foo'] - self.assertEquals( - fut(emails), - 'https://api.linkedin.com/v1/people::(email=foo@bar,email=bar@foo):' - '(id)?oauth2_access_token=777') - - def test_batch(self): - LinkedInToken(access_token='777').save() - api = self.make_one() - api.call_json_api = mock.Mock(return_value={ - 'values': [{'_key': 'email=bar@foo'}]}) - emails = ['foo@bar', 'bar@foo'] - self.assertEqual(list(api.batch(emails)), [False, True]) - - -class DummyCommand(object): - - def __init__(self): - self.stderr = StringIO.StringIO() - - -class DummyHTTPError(object): - - def __str__(self): - return 'HTTPError OMG!' - - def read(self): - return 'OMG OHNOES!' diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py deleted file mode 100644 index ae05c8161c93..000000000000 --- a/lms/djangoapps/linkedin/management/commands/tests/test_findusers.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -Tests for the findusers script. -""" -import datetime -import mock -import pytz -import StringIO - -from django.test import TestCase - -from linkedin.management.commands import linkedin_findusers as findusers - -MODULE = 'linkedin.management.commands.linkedin_findusers.' - - -class FindUsersTests(TestCase): - """ - Tests for the findusers script. - """ - - @mock.patch(MODULE + 'timezone') - def test_get_call_limits_in_safe_harbor(self, timezone): - """ - We should be able to perform unlimited API calls during "safe harbor". - """ - fut = findusers.get_call_limits - tzinfo = pytz.timezone('US/Eastern') - timezone.now.return_value = datetime.datetime( - 2013, 12, 14, 0, 0, tzinfo=tzinfo) - self.assertEqual(fut(), (-1, 80, 1)) - timezone.now.return_value = datetime.datetime( - 2013, 12, 13, 21, 1, tzinfo=tzinfo) - self.assertEqual(fut(), (-1, 80, 1)) - timezone.now.return_value = datetime.datetime( - 2013, 12, 15, 7, 59, tzinfo=tzinfo) - self.assertEqual(fut(), (-1, 80, 1)) - - @mock.patch(MODULE + 'timezone') - def test_get_call_limits_in_business_hours(self, timezone): - """ - During business hours we shouldn't be able to make any API calls. - """ - fut = findusers.get_call_limits - tzinfo = pytz.timezone('US/Eastern') - timezone.now.return_value = datetime.datetime( - 2013, 12, 11, 11, 3, tzinfo=tzinfo) - self.assertEqual(fut(), (0, 0, 0)) - timezone.now.return_value = datetime.datetime( - 2013, 12, 13, 20, 59, tzinfo=tzinfo) - self.assertEqual(fut(), (0, 0, 0)) - timezone.now.return_value = datetime.datetime( - 2013, 12, 16, 8, 1, tzinfo=tzinfo) - self.assertEqual(fut(), (0, 0, 0)) - - @mock.patch(MODULE + 'timezone') - def test_get_call_limits_on_weeknights(self, timezone): - """ - On weeknights outside of "safe harbor" we can only make limited API - calls. - """ - fut = findusers.get_call_limits - tzinfo = pytz.timezone('US/Eastern') - timezone.now.return_value = datetime.datetime( - 2013, 12, 11, 21, 3, tzinfo=tzinfo) - self.assertEqual(fut(), (500, 80, 1)) - timezone.now.return_value = datetime.datetime( - 2013, 12, 11, 7, 59, tzinfo=tzinfo) - self.assertEqual(fut(), (500, 80, 1)) - - @mock.patch(MODULE + 'time') - @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedInAPI') - @mock.patch(MODULE + 'get_call_limits') - def test_command_success_recheck_no_limits(self, get_call_limits, apicls, - usercls, time): - """ - Test rechecking all users with no API limits. - """ - fut = findusers.Command().handle - get_call_limits.return_value = (-1, 6, 42) - api = apicls.return_value - users = [mock.Mock(email=i) for i in xrange(10)] - usercls.objects.all.return_value = users - - def dummy_batch(emails): - "Mock LinkedIn API." - return [email % 2 == 0 for email in emails] - api.batch = dummy_batch - fut(recheck=True) - time.sleep.assert_called_once_with(42) - self.assertEqual([u.linkedin.has_linkedin_account for u in users], - [i % 2 == 0 for i in xrange(10)]) - - @mock.patch(MODULE + 'time') - @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedInAPI') - @mock.patch(MODULE + 'get_call_limits') - def test_command_success_no_recheck_no_limits(self, get_call_limits, apicls, - usercls, time): - """ - Test checking only unchecked users, with no API limits. - """ - fut = findusers.Command().handle - get_call_limits.return_value = (-1, 6, 42) - api = apicls.return_value - users = [mock.Mock(email=i) for i in xrange(10)] - for user in users[:6]: - user.linkedin.has_linkedin_account = user.email % 2 == 0 - for user in users[6:]: - user.linkedin.has_linkedin_account = None - usercls.objects.all.return_value = users - - def dummy_batch(emails): - "Mock LinkedIn API." - emails = list(emails) - self.assertEqual(len(emails), 4) - return [email % 2 == 0 for email in emails] - api.batch = dummy_batch - fut() - time.sleep.assert_not_called() - self.assertEqual([u.linkedin.has_linkedin_account for u in users], - [i % 2 == 0 for i in xrange(10)]) - - @mock.patch(MODULE + 'time') - @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedInAPI') - @mock.patch(MODULE + 'get_call_limits') - def test_command_success_no_recheck_no_users(self, get_call_limits, apicls, - usercls, time): - """ - Test no users to check. - """ - fut = findusers.Command().handle - get_call_limits.return_value = (-1, 6, 42) - api = apicls.return_value - users = [mock.Mock(email=i) for i in xrange(10)] - for user in users: - user.linkedin.has_linkedin_account = user.email % 2 == 0 - usercls.objects.all.return_value = users - - def dummy_batch(_): - "Mock LinkedIn API." - self.assertTrue(False) # shouldn't be called - api.batch = dummy_batch - fut() - time.sleep.assert_not_called() - self.assertEqual([u.linkedin.has_linkedin_account for u in users], - [i % 2 == 0 for i in xrange(10)]) - - @mock.patch(MODULE + 'time') - @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedInAPI') - @mock.patch(MODULE + 'get_call_limits') - def test_command_success_recheck_with_limit(self, get_call_limits, apicls, - usercls, time): - """ - Test recheck all users with API limit. - """ - command = findusers.Command() - command.stderr = StringIO.StringIO() - fut = command.handle - get_call_limits.return_value = (9, 6, 42) - api = apicls.return_value - users = [mock.Mock(email=i) for i in xrange(10)] - for user in users: - user.linkedin.has_linkedin_account = None - usercls.objects.all.return_value = users - - def dummy_batch(emails): - "Mock LinkedIn API." - return [email % 2 == 0 for email in emails] - api.batch = dummy_batch - fut() - time.sleep.assert_called_once_with(42) - self.assertEqual([u.linkedin.has_linkedin_account for u in users[:9]], - [i % 2 == 0 for i in xrange(9)]) - self.assertEqual(users[9].linkedin.has_linkedin_account, None) - self.assertTrue(command.stderr.getvalue().startswith("WARNING")) - - @mock.patch(MODULE + 'User') - @mock.patch(MODULE + 'LinkedInAPI') - @mock.patch(MODULE + 'get_call_limits') - def test_command_success_recheck_with_force(self, get_call_limits, apicls, - usercls): - """ - Test recheck all users with API limit. - """ - command = findusers.Command() - command.stderr = StringIO.StringIO() - fut = command.handle - get_call_limits.return_value = (9, 6, 42) - api = apicls.return_value - users = [mock.Mock(email=i) for i in xrange(10)] - for user in users: - user.linkedin.has_linkedin_account = None - usercls.objects.all.return_value = users - - def dummy_batch(emails): - "Mock LinkedIn API." - return [email % 2 == 0 for email in emails] - api.batch = dummy_batch - get_call_limits.return_value = (-1, 80, 1) - fut(force=True) - self.assertEqual([u.linkedin.has_linkedin_account for u in users], - [i % 2 == 0 for i in xrange(10)]) - - @mock.patch(MODULE + 'get_call_limits') - def test_command_no_api_calls(self, get_call_limits): - """ - Test rechecking all users with no API limits. - """ - from django.core.management.base import CommandError - fut = findusers.Command().handle - get_call_limits.return_value = (0, 0, 0) - with self.assertRaises(CommandError): - fut(recheck=True) diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index d1ada3663e37..e0fb55adab53 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -15,8 +15,8 @@ from xmodule.modulestore.tests.factories import CourseFactory from student.models import UserProfile -from linkedin.models import LinkedIn from xmodule.modulestore.tests.django_utils import mixed_store_config +from linkedin.models import LinkedIn from linkedin.management.commands import linkedin_mailusers as mailusers MODULE = 'linkedin.management.commands.linkedin_mailusers.' diff --git a/lms/djangoapps/linkedin/models.py b/lms/djangoapps/linkedin/models.py index 51e001effa90..4d7002a8ce74 100644 --- a/lms/djangoapps/linkedin/models.py +++ b/lms/djangoapps/linkedin/models.py @@ -12,11 +12,3 @@ class LinkedIn(models.Model): user = models.OneToOneField(User, primary_key=True) has_linkedin_account = models.NullBooleanField(default=None) emailed_courses = models.TextField(default="[]") # JSON list of course ids - - -class LinkedInToken(models.Model): - """ - For storing access token and authorization code after logging in to - LinkedIn. - """ - access_token = models.CharField(max_length=255) diff --git a/lms/envs/common.py b/lms/envs/common.py index 716f11ae2e2b..29d5c92bb861 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1143,3 +1143,18 @@ 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_API = { + 'COMPANY_NAME': 'edX', + +} + +############################ 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 a5d8b182ff2e..5e23b2060718 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -257,18 +257,6 @@ YOUTUBE_PORT = 8031 LTI_PORT = 8765 -############################ LinkedIn Integration ############################# -INSTALLED_APPS += ('linkedin',) -LINKEDIN_API = { - 'CLIENT_ID': '12345', - 'CLIENT_SECRET': 'SECRET', - 'REDIRECT_URI': 'http://bar.foo', - 'COMPANY_NAME': 'edX', - 'COMPANY_ID': '0000000', - 'EMAIL_FROM': 'The Team ', - 'TEST_MODE': True -} - ################### Make tests faster #http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/ @@ -318,3 +306,6 @@ VIRTUAL_UNIVERSITIES, microsites_root=ENV_ROOT / 'edx-platform' / 'test_microsites' ) + +######### LinkedIn ######## +LINKEDIN_API['COMPANY_ID'] = '0000000' From 469fab5875e5775f61b98e5a5f175e906d5acf1b Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 13 Jan 2014 15:13:27 -0500 Subject: [PATCH 40/48] Update copy for LinkedIn e-mail. --- lms/templates/linkedin/linkedin_email.html | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lms/templates/linkedin/linkedin_email.html b/lms/templates/linkedin/linkedin_email.html index 2fe663b9b6bd..54e8fd51a0d9 100644 --- a/lms/templates/linkedin/linkedin_email.html +++ b/lms/templates/linkedin/linkedin_email.html @@ -554,7 +554,7 @@ - Share Your edX Success on LinkedIn + Share your edX success on LinkedIn @@ -583,11 +583,7 @@ -%if num_courses==1: - Through a partnership with LinkedIn, the world's largest professional network, we've now made it even easier for you to showcase your success. We encourage you to share your edX certificate on your LinkedIn profile. Simply click the "Add to profile" button below. -%else: - Through a partnership with LinkedIn, the world's largest professional network, we've now made it even easier for you to showcase your success. We encourage you to share your edX certificates on your LinkedIn profile. Simply click the "Add to profile" buttons below. -%endif + Good news! We're now partnering 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 courses you’d like to include on LinkedIn. From fdf531aec08921a1fecfb472bd1b0e2318c21087 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 13 Jan 2014 15:15:46 -0500 Subject: [PATCH 41/48] Migrations for LinkedIn. Clean up common. Add the ability to dry-run the command without sending e-mail. Don't save courses sent during a dry run Switch to EmailMessage for LinkedIn so we can send HTML emails Update subject copy. Use correct name for CertificationName Fix up certificate url information. --- .../management/commands/linkedin_mailusers.py | 32 ++++++--- .../commands/tests/test_mailusers.py | 16 +++++ .../linkedin/migrations/0001_initial.py | 70 +++++++++++++++++++ .../linkedin/migrations/__init__.py | 0 lms/envs/common.py | 4 -- 5 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 lms/djangoapps/linkedin/migrations/0001_initial.py create mode 100644 lms/djangoapps/linkedin/migrations/__init__.py diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 32d04cd89819..75f097cca27b 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -7,7 +7,7 @@ import urllib from django.conf import settings -from django.core.mail import send_mail +from django.core.mail import EmailMessage from django.core.management.base import BaseCommand from django.template import Context from django.template.loader import get_template @@ -43,6 +43,13 @@ class Command(BaseCommand): "all users that have earned certificates to date to add their " "certificates. Afterwards the default, one email per " "certificate mail form will be used."),) + option_list = 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__() @@ -50,6 +57,7 @@ def __init__(self): def handle(self, *args, **options): whitelist = settings.LINKEDIN_API['EMAIL_WHITELIST'] grandfather = options.get('grandfather', False) + mock_run = options.get('mock_run', False) accounts = LinkedIn.objects.filter(has_linkedin_account=True) for account in accounts: user = account.user @@ -65,8 +73,9 @@ def handle(self, *args, **options): if not certificates: continue if grandfather: - self.send_grandfather_email(user, certificates) - emailed.extend([cert.course_id for cert in certificates]) + self.send_grandfather_email(user, certificates, mock_run) + if not mock_run: + emailed.extend([cert.course_id for cert in certificates]) else: for certificate in certificates: self.send_triggered_email(user, certificate) @@ -83,11 +92,11 @@ def certificate_url(self, certificate, grandfather=False): tracking_code = '-'.join([ 'eml', 'prof', # the 'product'--no idea what that's supposed to mean - course.org, # Partner's name + 'edX', # Partner's name course.number, # Certificate's name 'gf' if grandfather else 'T']) query = [ - ('pfCertificationName', certificate.name), + ('pfCertificationName', course.display_name_with_default), ('pfAuthorityName', settings.PLATFORM_NAME), ('pfAuthorityId', settings.LINKEDIN_API['COMPANY_ID']), ('pfCertificationUrl', certificate.download_url), @@ -99,7 +108,7 @@ def certificate_url(self, certificate, grandfather=False): ('force', 'true')] return 'http://www.linkedin.com/profile/guided?' + urllib.urlencode(query) - def send_grandfather_email(self, user, certificates): + 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. @@ -124,13 +133,14 @@ def send_grandfather_email(self, user, certificates): 'course_title': course_title, 'course_image_url': course_img_url, 'course_end_date': course_end_date, - 'linkedin_add_url': self.certificate_url(cert), + 'linkedin_add_url': self.certificate_url(cert, True), }) context = {'courses_list': courses_list, 'num_courses': len(courses_list)} body = render_to_string('linkedin/linkedin_email.html', context) - subject = 'Congratulations! Put your certificates on LinkedIn' - self.send_email(user, subject, body) + subject = '{}, Add your Achievements to your LinkedIn Profile'.format(user.profile.name) + if not mock_run: + self.send_email(user, subject, body) def send_triggered_email(self, user, certificate): """ @@ -153,4 +163,6 @@ def send_email(self, user, subject, body): """ fromaddr = settings.DEFAULT_FROM_EMAIL toaddr = '%s <%s>' % (user.profile.name, user.email) - send_mail(subject, body, fromaddr, (toaddr,)) + msg = EmailMessage(subject, body, fromaddr, (toaddr,)) + msg.content_subtype = "html" + msg.send() diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index e0fb55adab53..c6dc66c56d2b 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -115,8 +115,24 @@ def test_mail_users_grandfather(self): self.assertEqual(len(mail.outbox), 2) 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') + + def test_mail_users_grandfather_mock(self): + """ + test that we aren't sending anything when in mock_run mode + """ + fut = mailusers.Command().handle + fut(grandfather=True, mock_run=True) + self.assertEqual( + json.loads(self.fred.linkedin.emailed_courses), []) + self.assertEqual( + json.loads(self.barney.linkedin.emailed_courses), []) + self.assertEqual(len(mail.outbox), 0) def test_mail_users_only_new_courses(self): """ 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/envs/common.py b/lms/envs/common.py index 29d5c92bb861..a3cff7580c9c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1147,10 +1147,6 @@ def enable_microsites(microsite_config_dict, subdomain_branding, virtual_univers ##################### LinkedIn ##################### INSTALLED_APPS += ('django_openid_auth',) -LINKEDIN_API = { - 'COMPANY_NAME': 'edX', - -} ############################ LinkedIn Integration ############################# INSTALLED_APPS += ('linkedin',) From 0726211a300718d30403a6ebb49894fdee9a14fb Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 14 Jan 2014 10:34:34 -0500 Subject: [PATCH 42/48] Use S3 uploaded image for e-mail button. Add in a tracking pixel. Add in alt text for profile image --- lms/templates/linkedin/linkedin_email.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lms/templates/linkedin/linkedin_email.html b/lms/templates/linkedin/linkedin_email.html index 54e8fd51a0d9..c790126ef4d6 100644 --- a/lms/templates/linkedin/linkedin_email.html +++ b/lms/templates/linkedin/linkedin_email.html @@ -1,4 +1,3 @@ -<%namespace name='static' file='../static_content.html'/> @@ -640,7 +639,7 @@
## TODO put path/to/real/source/file here -
+
Add to profile
@@ -802,6 +801,8 @@ + +
Copyright © 2014 edX, All rights reserved.

From cc1d796b730cd1316611e04c6e64239aa493c86b Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 15 Jan 2014 08:46:01 -0500 Subject: [PATCH 43/48] Add email send error checking. Add manual transaction handling. Remove grandfather option --- .../management/commands/linkedin_mailusers.py | 188 +++++++++++++----- .../commands/tests/test_mailusers.py | 66 +----- 2 files changed, 144 insertions(+), 110 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 75f097cca27b..3e1935dfe283 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -3,12 +3,27 @@ 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 @@ -21,6 +36,53 @@ 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): """ @@ -32,18 +94,6 @@ class Command(BaseCommand): 'course certificates, inviting them to add their certificates to ' 'their LinkedIn profiles') option_list = BaseCommand.option_list + ( - make_option( - '--grandfather', - action='store_true', - dest='grandfather', - default=False, - help="Creates aggregate invitations for all certificates a user " - "has earned to date and sends a 'grandfather' email. This is " - "intended to be used when the feature is launched to invite " - "all users that have earned certificates to date to add their " - "certificates. Afterwards the default, one email per " - "certificate mail form will be used."),) - option_list = option_list + ( make_option( '--mock', action='store_true', @@ -54,36 +104,56 @@ class Command(BaseCommand): def __init__(self): super(Command, self).__init__() + @transaction.commit_manually def handle(self, *args, **options): whitelist = settings.LINKEDIN_API['EMAIL_WHITELIST'] - grandfather = options.get('grandfather', False) 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 - emailed = json.loads(account.emailed_courses) + + 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] + 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 - if grandfather: - self.send_grandfather_email(user, certificates, mock_run) + + # 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]) - else: - for certificate in certificates: - self.send_triggered_email(user, certificate) - emailed.append(certificate.course_id) - account.emailed_courses = json.dumps(emailed) - account.save() - - def certificate_url(self, certificate, grandfather=False): + 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 @@ -94,7 +164,7 @@ def certificate_url(self, certificate, grandfather=False): 'prof', # the 'product'--no idea what that's supposed to mean 'edX', # Partner's name course.number, # Certificate's name - 'gf' if grandfather else 'T']) + 'gf']) query = [ ('pfCertificationName', course.display_name_with_default), ('pfAuthorityName', settings.PLATFORM_NAME), @@ -133,36 +203,58 @@ def send_grandfather_email(self, user, certificates, mock_run=False): 'course_title': course_title, 'course_image_url': course_img_url, 'course_end_date': course_end_date, - 'linkedin_add_url': self.certificate_url(cert, True), + '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 = '{}, Add your Achievements to your LinkedIn Profile'.format(user.profile.name) - if not mock_run: - self.send_email(user, subject, body) + if mock_run: + return True + else: + return self.send_email(user, subject, body) - def send_triggered_email(self, user, certificate): - """ - Email a user that recently earned a certificate, inviting them to post - their certificate on their LinkedIn profile. - """ - template = get_template("linkedin_email.html") - url = self.certificate_url(certificate) - context = Context({ - 'student_name': user.profile.name, - 'course_name': certificate.name, - 'url': url}) - body = template.render(context) - subject = 'Congratulations! Put your certificate on LinkedIn' - self.send_email(user, subject, body) - - def send_email(self, user, subject, body): + def send_email(self, user, subject, body, num_attempts=MAX_ATTEMPTS): """ - Send an email. + Send an email. Return True if it succeeded, False if it didn't. """ fromaddr = settings.DEFAULT_FROM_EMAIL toaddr = '%s <%s>' % (user.profile.name, user.email) msg = EmailMessage(subject, body, fromaddr, (toaddr,)) msg.content_subtype = "html" - msg.send() + + i = 0 + 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( + "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/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index c6dc66c56d2b..942d6d9fb3e3 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -69,24 +69,6 @@ def setUp(self): course_id='TESTX/3/TEST3') cert3.save() - def test_mail_users(self): - """ - Test emailing users. - """ - 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(len(mail.outbox), 3) - self.assertEqual(mail.outbox[0].from_email, settings.DEFAULT_FROM_EMAIL) - self.assertEqual( - mail.outbox[0].to, ['Fred Flintstone ']) - self.assertEqual( - mail.outbox[1].to, ['Fred Flintstone ']) - self.assertEqual( - mail.outbox[2].to, ['Barney Rubble ']) @mock.patch.dict('django.conf.settings.LINKEDIN_API', {'EMAIL_WHITELIST': ['barney@bedrock.gov']}) @@ -107,7 +89,7 @@ def test_mail_users_grandfather(self): Test sending grandfather emails. """ fut = mailusers.Command().handle - fut(grandfather=True) + fut() self.assertEqual( json.loads(self.fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2']) self.assertEqual( @@ -127,53 +109,13 @@ def test_mail_users_grandfather_mock(self): test that we aren't sending anything when in mock_run mode """ fut = mailusers.Command().handle - fut(grandfather=True, mock_run=True) + fut(mock_run=True) self.assertEqual( json.loads(self.fred.linkedin.emailed_courses), []) self.assertEqual( json.loads(self.barney.linkedin.emailed_courses), []) self.assertEqual(len(mail.outbox), 0) - def test_mail_users_only_new_courses(self): - """ - Test emailing users, making sure they are only emailed about new - certificates. - """ - self.fred.linkedin.emailed_courses = json.dumps(['TESTX/1/TEST1']) - self.fred.linkedin.save() - fut = mailusers.Command().handle - fut() - fred = User.objects.get(username='fred') - self.assertEqual( - json.loads(fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2']) - self.assertEqual( - json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3']) - self.assertEqual(len(mail.outbox), 2) - self.assertEqual( - mail.outbox[0].to, ['Fred Flintstone ']) - self.assertEqual( - mail.outbox[1].to, ['Barney Rubble ']) - - def test_mail_users_barney_has_no_new_courses(self): - """ - Test emailing users, making sure they are only emailed about new - certificates. - """ - self.barney.linkedin.emailed_courses = json.dumps(['TESTX/3/TEST3']) - self.barney.linkedin.save() - fut = mailusers.Command().handle - fut() - fred = User.objects.get(username='fred') - self.assertEqual( - json.loads(fred.linkedin.emailed_courses), ['TESTX/1/TEST1', 'TESTX/2/TEST2']) - self.assertEqual( - json.loads(self.barney.linkedin.emailed_courses), ['TESTX/3/TEST3']) - self.assertEqual(len(mail.outbox), 2) - self.assertEqual( - mail.outbox[0].to, ['Fred Flintstone ']) - self.assertEqual( - mail.outbox[1].to, ['Fred Flintstone ']) - def test_certificate_url(self): self.cert1.created_date = datetime.datetime( 2010, 8, 15, 0, 0, tzinfo=utc) @@ -182,8 +124,8 @@ def test_certificate_url(self): self.assertEqual( fut(self.cert1), 'http://www.linkedin.com/profile/guided?' - 'pfCertificationName=TestX%2FIntro101&pfAuthorityName=edX&' + 'pfCertificationName=TEST1&pfAuthorityName=edX&' 'pfAuthorityId=0000000&' 'pfCertificationUrl=http%3A%2F%2Ftest.foo%2Ftest&pfLicenseNo=TESTX%2F1%2FTEST1&' 'pfCertStartDate=201005&_mSplash=1&' - 'trk=eml-prof-TESTX-1-T&startTask=CERTIFICATION_NAME&force=true') + 'trk=eml-prof-edX-1-gf&startTask=CERTIFICATION_NAME&force=true') From 2b6651529a34e0c04481a3ee7804f78fea3b3a44 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 15 Jan 2014 14:41:30 -0500 Subject: [PATCH 44/48] Add in test to test the transactions when we hit an unexpected error. Add tests for various error mail sending scenarios --- .../management/commands/linkedin_mailusers.py | 4 +- .../commands/tests/test_mailusers.py | 88 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 3e1935dfe283..ec431fbba423 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -223,8 +223,8 @@ def send_email(self, user, subject, body, num_attempts=MAX_ATTEMPTS): msg = EmailMessage(subject, body, fromaddr, (toaddr,)) msg.content_subtype = "html" - i = 0 - while i < num_attempts: + i = 1 + while i <= num_attempts: try: msg.send() return True # Happy path! diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index 942d6d9fb3e3..9bbc6244d760 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -1,10 +1,12 @@ """ 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 @@ -18,6 +20,7 @@ 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.' @@ -116,6 +119,26 @@ def test_mail_users_grandfather_mock(self): json.loads(self.barney.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) @@ -129,3 +152,68 @@ def test_certificate_url(self): '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() From 77a315c6a3f8baf0d1a17a418326bef7017e9b02 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 16 Jan 2014 14:28:46 -0500 Subject: [PATCH 45/48] Minor data collection tweaks after examining prod course data. Add in unicode handling for names. --- .../management/commands/linkedin_mailusers.py | 10 ++++----- .../commands/tests/test_mailusers.py | 22 +++++++++++++++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index ec431fbba423..4903203be291 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -191,11 +191,11 @@ def send_grandfather_email(self, user, certificates, mock_run=False): reverse('course_root', kwargs={'course_id': cert.course_id}) ) - course_title = course.display_name + 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.display_organization + course_org = course.org courses_list.append({ 'course_url': course_url, @@ -208,7 +208,7 @@ def send_grandfather_email(self, user, certificates, mock_run=False): context = {'courses_list': courses_list, 'num_courses': len(courses_list)} body = render_to_string('linkedin/linkedin_email.html', context) - subject = '{}, Add your Achievements to your LinkedIn Profile'.format(user.profile.name) + subject = u'{}, Add your Achievements to your LinkedIn Profile'.format(user.profile.name) if mock_run: return True else: @@ -219,7 +219,7 @@ 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 = '%s <%s>' % (user.profile.name, user.email) + toaddr = u'{} <{}>'.format(user.profile.name, user.email) msg = EmailMessage(subject, body, fromaddr, (toaddr,)) msg.content_subtype = "html" @@ -231,7 +231,7 @@ def send_email(self, user, subject, body, num_attempts=MAX_ATTEMPTS): except SINGLE_EMAIL_FAILURE_ERRORS: # Something unrecoverable is wrong about the email acct we're sending to log.exception( - "LinkedIn: Email send failed for user {}, email {}" + u"LinkedIn: Email send failed for user {}, email {}" .format(user.username, user.email) ) return False diff --git a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py index 9bbc6244d760..333913ead6d5 100644 --- a/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/tests/test_mailusers.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Test email scripts. """ @@ -51,9 +52,16 @@ def setUp(self): 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, @@ -71,7 +79,11 @@ def setUp(self): 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']}) @@ -97,7 +109,9 @@ def test_mail_users_grandfather(self): 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(len(mail.outbox), 2) + 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( @@ -106,6 +120,8 @@ def test_mail_users_grandfather(self): 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): """ @@ -117,6 +133,8 @@ def test_mail_users_grandfather_mock(self): 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): From c9718380eae0802eda5940fb6c87341402170460 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 15 Jan 2014 15:36:25 -0500 Subject: [PATCH 46/48] catch additional FEATURE['ENABLE_MKTG_SITE'] checks and make sure MicrositeConfigurations will override the global setting one more faulty login regarding ENABLE_MKTG_SITE=true situations --- common/djangoapps/edxmako/shortcuts.py | 9 +++++++-- lms/djangoapps/branding/views.py | 5 ++++- lms/djangoapps/courseware/views.py | 8 +++++++- lms/envs/cms/microsite_test.py | 6 +++++- lms/templates/navigation.html | 2 +- 5 files changed, 24 insertions(+), 6 deletions(-) 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/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/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/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: