diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index bea29aab9d..d85621287e 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -29,6 +29,17 @@ class CourseAccessRoleAdmin(admin.ModelAdmin): 'id', 'user', 'org', 'course_id', 'role' ) + +class LinkedInAddToProfileConfigurationAdmin(admin.ModelAdmin): + """Admin interface for the LinkedIn Add to Profile configuration. """ + + class Meta: + model = LinkedInAddToProfileConfiguration + + # Exclude deprecated fields + exclude = ('dashboard_tracking_code',) + + admin.site.register(UserProfile) admin.site.register(UserTestGroup) @@ -45,4 +56,4 @@ admin.site.register(CourseAccessRole, CourseAccessRoleAdmin) admin.site.register(DashboardConfiguration, ConfigurationModelAdmin) -admin.site.register(LinkedInAddToProfileConfiguration) +admin.site.register(LinkedInAddToProfileConfiguration, LinkedInAddToProfileConfigurationAdmin) diff --git a/common/djangoapps/student/migrations/0045_add_trk_partner_to_linkedin_config.py b/common/djangoapps/student/migrations/0045_add_trk_partner_to_linkedin_config.py new file mode 100644 index 0000000000..87128512d4 --- /dev/null +++ b/common/djangoapps/student/migrations/0045_add_trk_partner_to_linkedin_config.py @@ -0,0 +1,184 @@ +# -*- 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 field 'LinkedInAddToProfileConfiguration.trk_partner_name' + db.add_column('student_linkedinaddtoprofileconfiguration', 'trk_partner_name', + self.gf('django.db.models.fields.CharField')(default='', max_length=10, blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'LinkedInAddToProfileConfiguration.trk_partner_name' + db.delete_column('student_linkedinaddtoprofileconfiguration', 'trk_partner_name') + + + 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'}) + }, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseaccessrole': { + 'Meta': {'unique_together': "(('user', 'org', 'course_id', 'role'),)", 'object_name': 'CourseAccessRole'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '64', 'blank': 'True'}), + 'role': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.dashboardconfiguration': { + 'Meta': {'object_name': 'DashboardConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'recent_enrollment_time_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'student.linkedinaddtoprofileconfiguration': { + 'Meta': {'object_name': 'LinkedInAddToProfileConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'company_identifier': ('django.db.models.fields.TextField', [], {}), + 'dashboard_tracking_code': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'trk_partner_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '10', 'blank': 'True'}) + }, + 'student.loginfailures': { + 'Meta': {'object_name': 'LoginFailures'}, + 'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.passwordhistory': { + 'Meta': {'object_name': 'PasswordHistory'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usersignupsource': { + 'Meta': {'object_name': 'UserSignupSource'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'site': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 7af3830650..ea6afc5a09 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1467,34 +1467,88 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel): # Deprecated dashboard_tracking_code = models.TextField(default="", blank=True) - def add_to_profile_url(self, course_name, enrollment_mode, cert_url, source="o"): + trk_partner_name = models.CharField( + max_length=10, + default="", + blank=True, + help_text=ugettext_lazy( + u"Short identifier for the LinkedIn partner used in the tracking code. " + u"(Example: 'edx') " + u"If no value is provided, tracking codes will not be sent to LinkedIn." + ) + ) + + def add_to_profile_url(self, course_key, course_name, cert_mode, cert_url, source="o", target="dashboard"): """Construct the URL for the "add to profile" button. Arguments: + course_key (CourseKey): The identifier for the course. course_name (unicode): The display name of the course. - enrollment_mode (str): The enrollment mode of the user (e.g. "verified", "honor", "professional") + cert_mode (str): The course mode of the user's certificate (e.g. "verified", "honor", "professional") cert_url (str): The download URL for the certificate. Keyword Arguments: source (str): Either "o" (for onsite/UI), "e" (for emails), or "m" (for mobile) + target (str): An identifier for the occurrance of the button. """ params = OrderedDict([ ('_ed', self.company_identifier), - ('pfCertificationName', self._cert_name(course_name, enrollment_mode).encode('utf-8')), + ('pfCertificationName', self._cert_name(course_name, cert_mode).encode('utf-8')), ('pfCertificationUrl', cert_url), ('source', source) ]) + + tracking_code = self._tracking_code(course_key, cert_mode, target) + if tracking_code is not None: + params['trk'] = tracking_code + return u'http://www.linkedin.com/profile/add?{params}'.format( params=urlencode(params) ) - def _cert_name(self, course_name, enrollment_mode): + def _cert_name(self, course_name, cert_mode): """Name of the certification, for display on LinkedIn. """ return self.MODE_TO_CERT_NAME.get( - enrollment_mode, + cert_mode, _(u"{platform_name} Certificate for {course_name}") ).format( platform_name=settings.PLATFORM_NAME, course_name=course_name ) + + def _tracking_code(self, course_key, cert_mode, target): + """Create a tracking code for the button. + + Tracking codes are used by LinkedIn to collect + analytics about certifications users are adding + to their profiles. + + The tracking code format is: + &trk=[partner name]-[certificate type]-[date]-[target field] + + In our case, we're sending: + &trk=edx-{COURSE ID}_{COURSE MODE}-{TARGET} + + If no partner code is configured, then this will + return None, indicating that tracking codes are disabled. + + Arguments: + + course_key (CourseKey): The identifier for the course. + cert_mode (str): The enrollment mode for the course. + target (str): Identifier for where the button is located. + + Returns: + unicode or None + + """ + return ( + u"{partner}-{course_key}_{cert_mode}-{target}".format( + partner=self.trk_partner_name, + course_key=unicode(course_key), + cert_mode=cert_mode, + target=target + ) + if self.trk_partner_name else None + ) diff --git a/common/djangoapps/student/tests/test_linkedin.py b/common/djangoapps/student/tests/test_linkedin.py new file mode 100644 index 0000000000..ef399465de --- /dev/null +++ b/common/djangoapps/student/tests/test_linkedin.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""Tests for LinkedIn Add to Profile configuration. """ + +import ddt +from urllib import urlencode + +from django.test import TestCase +from opaque_keys.edx.locator import CourseLocator +from student.models import LinkedInAddToProfileConfiguration + + +@ddt.ddt +class LinkedInAddToProfileUrlTests(TestCase): + """Tests for URL generation of LinkedInAddToProfileConfig. """ + + COURSE_KEY = CourseLocator(org="edx", course="DemoX", run="Demo_Course") + COURSE_NAME = u"Test Course ☃" + CERT_URL = u"http://s3.edx/cert" + + @ddt.data( + ('honor', u'edX+Honor+Code+Certificate+for+Test+Course+%E2%98%83'), + ('verified', u'edX+Verified+Certificate+for+Test+Course+%E2%98%83'), + ('professional', u'edX+Professional+Certificate+for+Test+Course+%E2%98%83'), + ('default_mode', u'edX+Certificate+for+Test+Course+%E2%98%83') + ) + @ddt.unpack + def test_linked_in_url(self, cert_mode, expected_cert_name): + config = LinkedInAddToProfileConfiguration( + company_identifier='0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9', + enabled=True + ) + + expected_url = ( + 'http://www.linkedin.com/profile/add' + '?_ed=0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9&' + 'pfCertificationName={expected_cert_name}&' + 'pfCertificationUrl=http%3A%2F%2Fs3.edx%2Fcert&' + 'source=o' + ).format(expected_cert_name=expected_cert_name) + + actual_url = config.add_to_profile_url( + self.COURSE_KEY, + self.COURSE_NAME, + cert_mode, + self.CERT_URL + ) + + self.assertEqual(actual_url, expected_url) + + def test_linked_in_url_tracking_code(self): + config = LinkedInAddToProfileConfiguration( + company_identifier="abcd123", + trk_partner_name="edx", + enabled=True + ) + + expected_param = urlencode({ + 'trk': u'edx-{course_key}_honor-dashboard'.format( + course_key=self.COURSE_KEY + ) + }) + + actual_url = config.add_to_profile_url( + self.COURSE_KEY, + self.COURSE_NAME, + 'honor', + self.CERT_URL + ) + + self.assertIn(expected_param, actual_url) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index eb1a0ffbf9..bedf147b58 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -17,7 +17,6 @@ from django.contrib.sessions.middleware import SessionMiddleware from django.core.urlresolvers import reverse from django.test import TestCase from django.test.client import RequestFactory, Client -from django.test.utils import override_settings from mock import Mock, patch from opaque_keys.edx.locations import SlashSeparatedCourseKey @@ -49,13 +48,6 @@ log = logging.getLogger(__name__) class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" - def setUp(self): - super(CourseEndingTest, self).setUp() - - # Clear the model-based config cache to avoid - # interference between tests. - cache.clear() - def test_process_survey_link(self): username = "fred" user = Mock(username=username) @@ -198,118 +190,6 @@ class CourseEndingTest(TestCase): } self.assertIsNone(_cert_info(user, course2, cert_status, course_mode)) - def test_linked_in_url_with_unicode_course_display_name(self): - """Test with unicode display name values.""" - - user = Mock(username="fred") - survey_url = "http://a_survey.com" - course = Mock( - end_of_course_survey_url=survey_url, - certificates_display_behavior='end', - display_name=u'edx/abc/courseregisters®' - ) - download_url = 'http://s3.edx/cert' - - cert_status = { - 'status': 'downloadable', 'grade': '67', - 'download_url': download_url, - 'mode': 'honor' - } - LinkedInAddToProfileConfiguration( - company_identifier='0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9', - enabled=True - ).save() - - status_dict = _cert_info(user, course, cert_status, 'honor') - expected_url = ( - 'http://www.linkedin.com/profile/add' - '?_ed=0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9&' - 'pfCertificationName=edX+Honor+Code+Certificate+for+edx%2Fabc%2Fcourseregisters%C2%AE&' - 'pfCertificationUrl=http%3A%2F%2Fs3.edx%2Fcert&' - 'source=o' - ) - self.assertEqual(expected_url, status_dict['linked_in_url']) - - def test_linked_in_url_not_exists_without_config(self): - user = Mock(username="fred") - survey_url = "http://a_survey.com" - course = Mock( - display_name="Demo Course", - end_of_course_survey_url=survey_url, - certificates_display_behavior='end' - ) - - download_url = 'http://s3.edx/cert' - cert_status = { - 'status': 'downloadable', 'grade': '67', - 'download_url': download_url, - 'mode': 'verified' - } - - self.assertEqual( - _cert_info(user, course, cert_status, 'verified'), - { - 'status': 'ready', - 'show_disabled_download_button': False, - 'show_download_url': True, - 'download_url': download_url, - 'show_survey_button': True, - 'survey_url': survey_url, - 'grade': '67', - 'mode': 'verified', - 'linked_in_url': None - } - ) - - # Enabling the configuration will cause the LinkedIn - # "add to profile" button to appear. - # We need to clear the cache again to make sure we - # pick up the modified configuration. - cache.clear() - LinkedInAddToProfileConfiguration( - company_identifier='0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9', - enabled=True - ).save() - - status_dict = _cert_info(user, course, cert_status, 'honor') - expected_url = ( - 'http://www.linkedin.com/profile/add' - '?_ed=0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9&' - 'pfCertificationName=edX+Verified+Certificate+for+Demo+Course&' - 'pfCertificationUrl=http%3A%2F%2Fs3.edx%2Fcert&' - 'source=o' - ) - self.assertEqual(expected_url, status_dict['linked_in_url']) - - @ddt.data( - ('honor', 'edX Honor Code Certificate for DemoX'), - ('verified', 'edX Verified Certificate for DemoX'), - ('professional', 'edX Professional Certificate for DemoX'), - ('default_mode', 'edX Certificate for DemoX') - ) - @ddt.unpack - def test_linked_in_url_certificate_types(self, cert_mode, cert_name): - user = Mock(username="fred") - course = Mock( - display_name='DemoX', - end_of_course_survey_url='http://example.com', - certificates_display_behavior='end' - ) - cert_status = { - 'status': 'downloadable', - 'grade': '67', - 'download_url': 'http://edx.org', - 'mode': cert_mode - } - - LinkedInAddToProfileConfiguration( - company_identifier="abcd123", - enabled=True - ).save() - - status_dict = _cert_info(user, course, cert_status, cert_mode) - self.assertIn(cert_name.replace(' ', '+'), status_dict['linked_in_url']) - class DashboardTest(ModuleStoreTestCase): """ @@ -518,10 +398,7 @@ class DashboardTest(ModuleStoreTestCase): @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') def test_linked_in_add_to_profile_btn_not_appearing_without_config(self): - - """ - without linked-in config don't show Add Certificate to LinkedIn button - """ + # Without linked-in config don't show Add Certificate to LinkedIn button self.client.login(username="jack", password="test") CourseModeFactory.create( @@ -557,12 +434,8 @@ class DashboardTest(ModuleStoreTestCase): @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') def test_linked_in_add_to_profile_btn_with_certificate(self): - - """ - If user has a certificate with valid linked-in config then Add Certificate to LinkedIn button - should be visible. and it has URL value with valid parameters. - """ - + # If user has a certificate with valid linked-in config then Add Certificate to LinkedIn button + # should be visible. and it has URL value with valid parameters. self.client.login(username="jack", password="test") LinkedInAddToProfileConfiguration( company_identifier='0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9', diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index ee69b8bd10..bbb3482a9b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -361,6 +361,7 @@ def _cert_info(user, course, cert_status, course_mode): linkedin_config = LinkedInAddToProfileConfiguration.current() if linkedin_config.enabled: status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( + course.id, course.display_name, cert_status.get('mode'), cert_status['download_url'] diff --git a/lms/djangoapps/certificates/management/commands/create_fake_cert.py b/lms/djangoapps/certificates/management/commands/create_fake_cert.py new file mode 100644 index 0000000000..7c063ed1b3 --- /dev/null +++ b/lms/djangoapps/certificates/management/commands/create_fake_cert.py @@ -0,0 +1,110 @@ +"""Utility for testing certificate display. + +This command will create a fake certificate for a user +in a course. The certificate will display on the student's +dashboard, but no PDF will be generated. + +Example usage: + + $ ./manage.py lms create_fake_cert test_user edX/DemoX/Demo_Course --mode honor --grade 0.89 + +""" +import logging +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth.models import User +from optparse import make_option +from opaque_keys.edx.keys import CourseKey +from certificates.models import GeneratedCertificate, CertificateStatuses + + +LOGGER = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Create a fake certificate for a user in a course. """ + + USAGE = u'Usage: create_fake_cert --mode --status --grade ' + + option_list = BaseCommand.option_list + ( + make_option( + '-m', '--mode', + metavar='CERT_MODE', + dest='cert_mode', + default='honor', + help='The course mode of the certificate (e.g. "honor", "verified", or "professional")' + ), + + make_option( + '-s', '--status', + metavar='CERT_STATUS', + dest='status', + default=CertificateStatuses.downloadable, + help='The status of the certificate' + ), + + make_option( + '-g', '--grade', + metavar='CERT_GRADE', + dest='grade', + default='', + help='The grade for the course, as a decimal (e.g. "0.89" for 89%)' + ), + ) + + def handle(self, *args, **options): + """Create a fake certificate for a user. + + Arguments: + username (unicode): Identifier for the certificate's user. + course_key (unicode): Identifier for the certificate's course. + + Keyword Arguments: + cert_mode (str): The mode of the certificate (e.g "honor") + status (str): The status of the certificate (e.g. "downloadable") + grade (str): The grade of the certificate (e.g "0.89" for 89%) + + Raises: + CommandError + + """ + if len(args) < 2: + raise CommandError(self.USAGE) + + user = User.objects.get(username=args[0]) + course_key = CourseKey.from_string(args[1]) + cert_mode = options.get('cert_mode', 'honor') + status = options.get('status', CertificateStatuses.downloadable) + grade = options.get('grade', '') + + cert, created = GeneratedCertificate.objects.get_or_create( + user=user, + course_id=course_key + ) + cert.mode = cert_mode + cert.status = status + cert.grade = grade + + if status == CertificateStatuses.downloadable: + cert.download_uuid = 'test' + cert.verify_uuid = 'test' + cert.download_url = 'http://www.example.com' + + cert.save() + + if created: + LOGGER.info( + u"Created certificate for user %s in course %s " + u"with mode %s, status %s, " + u"and grade %s", + user.id, unicode(course_key), + cert_mode, status, grade + ) + + else: + LOGGER.info( + u"Updated certificate for user %s in course %s " + u"with mode %s, status %s, " + u"and grade %s", + user.id, unicode(course_key), + cert_mode, status, grade + ) diff --git a/lms/djangoapps/certificates/tests/test_create_fake_cert.py b/lms/djangoapps/certificates/tests/test_create_fake_cert.py new file mode 100644 index 0000000000..47d615a1c5 --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_create_fake_cert.py @@ -0,0 +1,52 @@ +"""Tests for the create_fake_certs management command. """ +from django.test import TestCase +from django.core.management.base import CommandError + +from opaque_keys.edx.locator import CourseLocator +from student.tests.factories import UserFactory +from certificates.management.commands import create_fake_cert +from certificates.models import GeneratedCertificate + + +class CreateFakeCertTest(TestCase): + """Tests for the create_fake_certs management command. """ + + USERNAME = "test" + COURSE_KEY = CourseLocator(org='edX', course='DemoX', run='Demo_Course') + + def setUp(self): + super(CreateFakeCertTest, self).setUp() + self.user = UserFactory.create(username=self.USERNAME) + + def test_create_fake_cert(self): + # No existing cert, so create it + self._run_command( + self.USERNAME, + unicode(self.COURSE_KEY), + cert_mode='verified', + grade='0.89' + ) + cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.COURSE_KEY) + self.assertEqual(cert.status, 'downloadable') + self.assertEqual(cert.mode, 'verified') + self.assertEqual(cert.grade, '0.89') + self.assertEqual(cert.download_uuid, 'test') + self.assertEqual(cert.download_url, 'http://www.example.com') + + # Cert already exists; modify it + self._run_command( + self.USERNAME, + unicode(self.COURSE_KEY), + cert_mode='honor' + ) + cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.COURSE_KEY) + self.assertEqual(cert.mode, 'honor') + + def test_too_few_args(self): + with self.assertRaisesRegexp(CommandError, 'Usage'): + self._run_command(self.USERNAME) + + def _run_command(self, *args, **kwargs): + """Run the management command to generate a fake cert. """ + command = create_fake_cert.Command() + return command.handle(*args, **kwargs) diff --git a/lms/static/images/linkedin_add_to_profile.png b/lms/static/images/linkedin_add_to_profile.png index d72b680f49..22fdb3a4f7 100644 Binary files a/lms/static/images/linkedin_add_to_profile.png and b/lms/static/images/linkedin_add_to_profile.png differ diff --git a/lms/static/js/dashboard/legacy.js b/lms/static/js/dashboard/legacy.js index fb7b6c5565..c0651a6b6d 100644 --- a/lms/static/js/dashboard/legacy.js +++ b/lms/static/js/dashboard/legacy.js @@ -51,6 +51,21 @@ // Track clicks of the "verify now" button. window.analytics.trackLink(verifyButtonLinks, 'edx.bi.user.verification.resumed', generateProperties); + // Track clicks of the LinkedIn "Add to Profile" button + window.analytics.trackLink( + $('.action-linkedin-profile'), + 'edx.bi.user.linkedin_add_to_profile', + function( element ) { + var $el = $( element ); + return { + category: 'linkedin', + label: $el.data('course-id'), + mode: $el.data('certificate-mode') + }; + } + ); + + // Generate the properties object to be passed along with business intelligence events. function generateProperties(element) { var $el = $(element), diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 0a26ab7dc3..4815a80e21 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -612,8 +612,11 @@ // ==================== // UI: message - .message { + .wrapper-message-primary { @include clearfix(); + } + + .message { border-radius: 3px; display: none; z-index: 10; @@ -887,13 +890,18 @@ } &.course-status-processing { - + background-color: $gray-l5; + border: 0; } &.course-status-certnotavailable { + background-color: $gray-l5; + border: 0; } &.course-status-certrendering { + background-color: $gray-l5; + border: 0; .cta { margin-top: 2px; @@ -907,10 +915,10 @@ .message-copy { width: flex-grid(6, 12); position: relative; - float: left; + @include float(left); } - .actions { + .actions-primary { width: flex-grid(6, 12); position: relative; @include float(right); @@ -950,6 +958,24 @@ } } + .actions-secondary { + margin-top: ($baseline/2); + border-top: 1px solid $gray-l4; + padding-top: ($baseline/2); + + .action-share { + @include float(right); + margin: 0; + } + } + + .certificate-explanation { + @extend %t-copy-sub1; + margin-top: ($baseline/2); + border-top: 1px solid $gray-l4; + padding-top: ($baseline/2); + } + .verification-reminder { width: flex-grid(8, 12); position: relative; diff --git a/lms/templates/dashboard/_dashboard_certificate_information.html b/lms/templates/dashboard/_dashboard_certificate_information.html index 109136e9eb..eeab1280d4 100644 --- a/lms/templates/dashboard/_dashboard_certificate_information.html +++ b/lms/templates/dashboard/_dashboard_certificate_information.html @@ -1,6 +1,7 @@ <%page args="cert_status, course, enrollment" /> <%! from django.utils.translation import ugettext as _ %> +<%namespace name='static' file='../static_content.html'/> <% cert_name_short = course.cert_name_short @@ -25,61 +26,76 @@ else:
% if cert_status['status'] == 'processing': -

${_("Final course details are being wrapped up at this time. Your final standing will be available shortly.")}

+

${_("Final course details are being wrapped up at this time. Your final standing will be available shortly.")}

% elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'): -

${_("Your final grade:")} - ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. - % if cert_status['status'] == 'notpassing' and enrollment.mode != 'audit': - ${_("Grade required for a {cert_name_short}:").format(cert_name_short=cert_name_short)} - ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}. - % elif cert_status['status'] == 'restricted' and enrollment.mode == 'verified': -

- ${_("Your verified {cert_name_long} is being held pending confirmation that the issuance of your {cert_name_short} is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting {email}. If you would like a refund on your {cert_name_long}, please contact our billing address {billing_email}").format(email='{email}.'.format(email=settings.CONTACT_EMAIL), billing_email='{email}'.format(email=settings.PAYMENT_SUPPORT_EMAIL), cert_name_short=cert_name_short, cert_name_long=cert_name_long)} -

- % elif cert_status['status'] == 'restricted': -

- ${_("Your {cert_name_long} is being held pending confirmation that the issuance of your {cert_name_short} is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting {email}.").format(email='{email}.'.format(email=settings.CONTACT_EMAIL), cert_name_short=cert_name_short, cert_name_long=cert_name_long)} -

- % endif -

+

${_("Your final grade:")} + ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. + % if cert_status['status'] == 'notpassing' and enrollment.mode != 'audit': + ${_("Grade required for a {cert_name_short}:").format(cert_name_short=cert_name_short)} + ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}. + % elif cert_status['status'] == 'restricted' and enrollment.mode == 'verified': +

+ ${_("Your verified {cert_name_long} is being held pending confirmation that the issuance of your {cert_name_short} is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting {email}. If you would like a refund on your {cert_name_long}, please contact our billing address {billing_email}").format(email='{email}.'.format(email=settings.CONTACT_EMAIL), billing_email='{email}'.format(email=settings.PAYMENT_SUPPORT_EMAIL), cert_name_short=cert_name_short, cert_name_long=cert_name_long)} +

+ % elif cert_status['status'] == 'restricted': +

+ ${_("Your {cert_name_long} is being held pending confirmation that the issuance of your {cert_name_short} is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting {email}.").format(email='{email}.'.format(email=settings.CONTACT_EMAIL), cert_name_short=cert_name_short, cert_name_long=cert_name_long)} +

+ % endif +

% endif % if cert_status['show_disabled_download_button'] or cert_status['show_download_url'] or cert_status['show_survey_button']: -
diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 6a20a1e2e3..697212378f 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -136,7 +136,7 @@ from student.helpers import ( % endif % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY] and not is_course_blocked: -
+
% if verification_status['status'] == VERIFY_STATUS_NEED_TO_VERIFY:
% if verification_status['days_until_deadline'] is not None: