From f854726efcaadfa9383abc90efcaad68114919cd Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 18 Feb 2015 17:47:19 -0500 Subject: [PATCH] Add tracking code to LinkedIn URL. Add client-side analytics event for LinkedIn Add to Profile button click. Add management command for generating certs for testing. Update styling of the certificate messages. --- common/djangoapps/student/admin.py | 13 +- ...0045_add_trk_partner_to_linkedin_config.py | 184 ++++++++++++++++++ common/djangoapps/student/models.py | 64 +++++- .../djangoapps/student/tests/test_linkedin.py | 70 +++++++ common/djangoapps/student/tests/tests.py | 133 +------------ common/djangoapps/student/views.py | 1 + .../management/commands/create_fake_cert.py | 110 +++++++++++ .../tests/test_create_fake_cert.py | 52 +++++ lms/static/images/linkedin_add_to_profile.png | Bin 1119 -> 139290 bytes lms/static/js/dashboard/legacy.js | 15 ++ lms/static/sass/multicourse/_dashboard.scss | 34 +++- .../_dashboard_certificate_information.html | 112 ++++++----- .../dashboard/_dashboard_course_listing.html | 2 +- 13 files changed, 601 insertions(+), 189 deletions(-) create mode 100644 common/djangoapps/student/migrations/0045_add_trk_partner_to_linkedin_config.py create mode 100644 common/djangoapps/student/tests/test_linkedin.py create mode 100644 lms/djangoapps/certificates/management/commands/create_fake_cert.py create mode 100644 lms/djangoapps/certificates/tests/test_create_fake_cert.py 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 d72b680f49ebdac14c2890d8392f5e839a27bd70..22fdb3a4f72c782963fea6e03f999db5bf06d83f 100644 GIT binary patch literal 139290 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 Qo~^s8*n zTn*sgFQHDcaYLETPzk=TYBnxG8qL=kc*%Ooj)0&&rFZM5V3PHrqGh*gH{ZrqE+T^= z127?>gD@>>R3mFEZ0&XvW{`Ir;2IA@{+YKGN`PqWV#zC*SMzPeI~j~B)v=KZ8w8-<_b7*~aUO`X=tF+7?7Qm-0ft06Fs)kDkuZRW7GVS~%CI_-E`h=VA30X? zNV$zA9*k0}yr~kl^V15q)`e;4iNO;tQ&p<32{2v2PQ^FzX=qmPk4h;vp+M1s;ROju zoyi2oxt!hK>v5h(#i33xs`gztp(=s7!#nb?Y|J=P$Kkl2Q2m!@ySzXno}d-EPAuWL za3y5ZS+_aJYPCDud0VnJlK2U7qwvfrR=`E@Qo;a=IZ#mufG7*RYX)uTVlR_3<}lvo zbI_4tj2*ktP<;?~;W$e~i>!pM4aNzV3HGO2DYgtU2#XeFY>2k|-BEB$IEjkQ>fY@^ z%iIz%>>`r8{^+V#kqE^4=6GjYw!%NcU`iO9md5HvohxzNB2KDj`l3 z-j~R6aqcImz8H&(X4t`!LapXqV8u9*T++xg`9bbXd?MuG;YO_lgCe&Om*Kv2`p7gn z^8(qFx-zsP-GmtozzvWh~tmk$|s#Zr+r+gJ(ta)AdTKthN!4l+=bHx=x&44?$jU=#c)6Sy{q zIEu}93%%fRI7EG)&$bN3SYi&gw)rUId$1zGI|$oc^C#GBcxGWIF}s?#(Wg4gB~X)7 zwgCk<1r9+jX~{zqP@GbR)T65cv)agnyJd zBqSUR8jGali}kNsC{C1UXu}X_pou1MFbtrog|iGG?~S7{gF~S}Fh=kzkj0wM0aovD z01yL4s@QjhzFBP(LE|j@Fl7K_DvlWIqL8;Dwgq$C)pQb-x|%(c+YAgYIwNozd|LiB z5Vs>q0#v1;&7p3|hL)%Tsw4rx$Upl9lUlR%@{Ue(oM?c4)TzT3!gT|JlQegN3jm!- zpc|J?@~B_&tBQVjVKU!=tKbw>0*pN2E+~@?5axBz^kj}u^^W>2cno0@l{>*ViYSz5 zbD$G)oa3j$$40~Jhj@a$grm$1!^ATS2Lc3QAs$pUvS`$-l;4{V5^pX<@!=x9K=KNf za17j+3!6#8C$I$py&-yY@e012o7;rjBxkCMgo7m8QK(kanRI1wss;XXfCD45d&h@qzfb*VAkP!N8GZ&{3^zNEb*ciA&f@FiR*8R5_OTgz=9>5QRtC2Kzfp*abuVya zpHwIk2H?mwk?G3ghZL8PqvRY;q^1Ops(An;D#aL-_Y8~~gd!m;LJ6k=vre!F(gjJ9 z)hi{M5Ey(u$r|)QN{d0?Y!l(1ng$e%#i>#@g>#L%`ZU#or5>$jlUB*sqK(J$3GIT| z7YGo>HiDM*OgM>XPXLVQ@`P9n#5yqo0zb1ZR%o`$RrH={K=aG^=1Q>^RktrRh^`)B zck?a)WwBGmhlt+eXv?vYBN2OBTMhr5Fo0_irLHU>2$--i6Q4-r?Q}EI2?NyVxdH)U znWb-`Dhqj|TTqz>M>1gmo>-ZAR1WZ&96tUCUwnwMC)P^H7jXMpV0;DFb{4ci$sQZ0 zXEoSSj&<`4xMuVz4AE%p3T&P+bdi*DA8P~2(-GyVk|gxKvmT>BO$L@-iBdn9 z!o7rTAm|TvS@R-_3oN|jqcq1y12hUF^+pOuoJ)d18A82S&$z@=wH)p;GMllMUok`3 z9BlK;yf}LV$`Tdzplbet=qgBQ)d^%@$YE>5q~l!lHIB&IM~)&Q1TQ6EOz@3BKX*sR z2&!=)4%^0M)C$$%9295Z zY2brOv98s;h@7z~)9n!>Dt(T4DnC6$;{YqUUyYxjzzWe9oCjeV44`&jUow39x9lM4GOj5NrDChZevRm z1wW{%PXCcWK_c6R>8Ttlnl^s|$IQ12^u(tsWjf$_2#J?UK`>Vn2GHcME!Gv(y^cFB z-2`M{TSpgr$~J1j0|a-#`FWH(g}Oa(8WhDRNuio&PzlL38)T3Rzy~N2FH*cG>cD?a zAilRnQcTLEK-+;4(y)wNP7NFoZ{O$*hTT2iAJZHex?PkKBH| zDsc)31Cl~c27wr&)Eplm;!u&muT$kxa0MwzKMN!;bVq$!2}e1)RXR9u(JsL^xR*gq z!nz;z0saZMvD=cQbME2MJ2|2;;sj(1i-dW|5)Kj|8|hnm;WP#x1oATB6(Q(AHJhz+ zy9V8X9<*)iY_*MJych<-HJ~Dq&~dG(2g*lZ5|c^5$1*Nldj3vAD}PDY1#w~>oqUR8 zif!12Y&7nG1l;m=6V?EMPr8B}SmID=t*m?lfR)IQoVH9^0~@HA0l!glmu4W!BF6=k z$gn4Uf~pWixhv;YHO7!tbN0JQ)r5O&PI;u=K(xsG++KezrS6g^6fX#EN}sFTMxrkm z7tbwkb2)EUK!@e^jxC6m`VBg^a3Aa(l395haf3yr|J^6-C9)UlsT_16C z!hy_)guUW@LTA$eBPo(Dl6Ah+9SjK=WirKH>MYJ8c+Vm>hq_VTCZ!T|eN~knr7Kqm z9|bu|k4yrQg;7={6R@l7saGqSqal!UU?Fr3CvdivHljiS%9i#<9xq>0HXlP3BrXXu zS9}3x9XuaC4oi=uL+u?`RTF~Cop267`@$^AuAh&_XMfTBM2l=$wwV`JRL(=wX zSKwqZ0bC|)D}t5(G!LLNlnRb=8yW}O)^~s-@iI>IxFr}>_#YEuC0rwrmXOD(5@u=v z!Ej3O&O-&hfhL^j5_c+{Uw)Bx!lVPYN>~GRLYaQhRP|H#1pmsF=m+eZQ4@Om6j@0} zKE{=zUncvW)=wq z2I*-KE=2i+!rW{Dr`n^K$F)GfZKjc^P7Cx}3R5^A)WXzCL8085-tp+!Zr%mNY`y!X zPO|XGVgoJ0qZ+eKKHN$W+7jApB2ja;4caW@2n|Ix1LY2BUqb%^x8}s?bC!9cY67z{ zi4feng*s-65{JxW932$xTnCpNR-;^q>!_WHInjNa*8t$)^h1?pN~~%wd0|k4FPa6< zE@c3$9fdAZJcvx%DI!i%fD^Ci4{N>)9o7e{jBrBWSz^md##f41V8o6{GZfaLM-$D6 z9kdBPSxXaQBDg)*Wf;*`<0pu2IYHJ#JCMAAqwZ;(u!a7tYT0~{@Z44URb$lpG<$1B zh-1+ownUpOTeDf$yQA2u%UdvzgL4O<5CA)Hop*!IHh+SGqi#6lLbY>wue;F?W*9(C zxSG(*qrjSLLP6P9X;VO#`bgRw%ZTDr+L5qc_@q$4(2pd_Aq5M-&8!_5>X zbX~wg<^BqF18j~2MHUrnPOZb7an4wxidC>QVL}o4U$;40SFR4Sv1F6hA50Wc)s>X+ zQ2JqC6D|`4%aPnh$vtFnL_2_O7s6L)Nf{B#IJin-8#V{$@N%Z+a_UE00v)BwA3c3+6mQ5 znE|~4r#KpW0Ut!2C`W+6z&HqZDR&`s7O3_iGopIh2VJ`WPGo*pL&dOh5dq4C4$)dE zD!Z8Qb=}4xtHVkGH=7*``a~BJ&6R^8+$UT%oF)WQ5MhWOCCng%OJMnG2~a!;p6XjR z_K%bbWbn-ckYdsMg0~a8+>istSsDij;HD5G5(enA2&7BlKcS?mCu9)iUfZD7G;X#B zU_7joss}z8K>ylEWmwa$1C)-QYV%Rnc0{BgGzcnt*W;aBNARF5h{8@n?VV=>88B)g zRzNo}+F9U$Xk}47OVHKTVGBj41T*L(fnXqr`a+t?4MTrU0A&rJv<5;BwE&75dS7ZK zM-4*$PC&1D7hEA+3mJk^hbAOxO2)D%l>vu@jtK)q&kqdJ-3x7&K-pcQ3F0r0Yqm&! z6@obUCwelOf#~ofWfC}=I%qt3i^b zipsKpt9hmbgJSJ~w*;-#=@_%X^ZG-$kdhr#Ai*XgFF^C)1wc5t4n$kPCGrKOxZeqP zLABLE#5kr>9<2x*L-d45L%5rS?tviqvgu0SnD&f11q@Qz&NOs4RDzm!fwH+ITR&8^ zF!*xh0bw&_uaD-M&^-`}&dlLRj-o+Xzy(;29x22e%2^5J0ez5|C#*{Z{25scY;L{ z*{C&xKsjff(tsctwE8_*HL#fGU7%Zln(8d+(B)wiHiD%!Atp&!nzs=nmkSjP={E>F z!Z${nUt}PCwa{kQrpsLmpT0VDfzVv!6wq9UfkH$nxcMkY;ky*na!oEF4t~ViKn=4Z zJ)S`J<$+O%;>Cby8i=bX$kR*|N}{?E5{Rmrtj*yd7z~fgv!WL}yjnKCs zt(%Cfkp=@kmNO^?(zZ`YvN=-4zd=z9)NI{Bi2x+<{HP;34J%L_05kA1uXGB%NSz<;Y zIAQu2ax{QugCa{2VmtK`3{rnkm=IdGwV{Sc8l?FgXdVbDBb`K{Elm(p4$8(MX(Cal ztO4a0bh>zQK#;GCgH39A=m-+vIh9d!h=T>hBZnIahrm%{(kOVrmWA+A$L3F9hZO=K ziM*-2!NF9i0|Wtu$#OJ@IA}=9wSHOX&Z&)LGH`Z5<|Pp_pM)8pd!Qm>@4)nhdO!$s zfJ4at5r>-x=$onk!xzw$7)AIX)1#l&kG7%Uu$>NN^^irlFSx%~7SAuZ87F+AOWGxw%Bbfq>8>?F0ZQwCXpGm_y#H z00~x0_ylQzUk;H9b|(~gF9eZNkF3>YvNFAy}Zfpjt6z)>MXWw7$- zikg5VgE$o^s0jl|Iw{dQ0Fi$~rkzL#vsKlN{DK4@m{0h zqco}F8Do`!x5+{%qc$%hpaLuoMpI~twnq7@14)Y`+R74?c|uwZD=q{FEhcZk)%gm2 zo}z@5(zzs!bVd9>jiOL})MdY4duGUb=tFM)gh({_A`z;AJYhHxZw418x*oBuQU*ZS zAZ)Gk#0n6PQ^JR#AqU!pgrY64t|gECRPChk&LN?8YQDv9LP$z@{UGQLA_9CGXDGxl zNyKP#L%@$(c!E-weA@=dFiZmHSx*&l5UtyIHIlhu!Wy8j5&FlD7TlsA$W}6yrV5^d zvzbsXq{wXl5UkY{ME8@W$0_nSlO<46ZrlagKM4vDgkpD8`+%YdmT|R0f<2*7h4&J4 zUtjImK}Hc!f(+*7Dsev4WgYbQb-2YXVo;P&;e2g2;UBR^46L#fo>k^t@J@{0)IB4&ZMtXnU!Uv zYJ+k?jK(p-T32?-8=)39&rz=jFFb%+q)C}jaNH}8V=hvfnvn9`{6zz%|eA(fO!7tP7H ze!6%XO?`?-Cgc*jxQf7l2=;&!Me__4xRes~=?9$J$Ke4-hKyfu4PA}q0dVF+w4ZSg zIb&wYJD6B;(oX{VgXZKLimCGj+u9}y4$WV~q%$qhlYSNn=OCWkh)!*4D%d2FC*}|c zM<_?5R)TZw^+Gf*gp81a0R&bN99bqf z78E8p8iyjfrXk5T$Q{rLz@k_bDFZl!JYiI6AYxcJA{0y>!2%bTfa2ijJNP1^X!(^V zVdBJvWM*kT;kGrO14e{fn{?UAcPEm-LW353H6DdYu!$Zp)xaV5Mn4c~>c&uc*0`+7 zKsr0&E=cpqw<>zt|7gtPpeq+s%Eu}vblTFwm1JY`OL!3zkVlM5K)zV zO5Cu#P#l|Qp#2W77l9u+%^?mpDL`0A(Zy|J&A0^@BDesQ{}mmZ7qN|8 z$gu(V;U^=N5lxyp$Z4KqRiqqcV2>su6-t?2v=aukPrPIsDl^~Bp_68+5AfS|lZ6ux|x`rcReMF=Nix4P#hu-2K z$0W=^+Yk^l^fy`P=tqj|QRpOR0b?`|z=ea+r3Q*k!EA+K5$X2z@fJb?D@;ri+Nrc) zARzptrr%&^cB1&4Bvu{+2(E0T6bs83f~J5j_wi@eI^jCwu>=Lt92?RqY;V5HmIQRC^Gkw8`X)BmUfk(h6 zqfQw>qJXzB(m27en?Bhw6>O-0I!gkjO1BBn!9!*81GTORDbIzp5-x=uXG2GCg-*|!=z*+E@Q@sceKKjj*9+2*tl#~sI)JLVN8Dq8{; zMG{qLrBub+<{7Y~ejFYtR@L+XQIB`7K!gNEQdFuiKUC>u%uW*l)7rZr{EG)!Gpa*O zO2Q06)>dDc2Gxi;;aQj%+A2bPfDl5$0Q#Te-MlW>;vJev*DiYoj|zHSf=wKSGqdo3 z7`GZGhIt$c4uU5~su@EG@|uD(0z)S40is06A6=*MYO{51{sgyBSn0h5W#8eSa;9xL%6gi?CU?nO|@iIy? zhN9S~ZzI-G`wcP({dkr|4P75f$eK3KfD=ao!Q|3WB`k`LJc=;USpr;5DB7x%O0z;L z+|R2~c~e6p55~6336g7GL_wkxC|Q)Yf)TVl1hyaKeL+`0q56+75~6w9>G){M)`EPO zQqTURw_}*Fh&rI;N-`WQSxhRw+@uIw>XZzn0^%{Lz{Pf|7Q!vTDH&q~7=D z0VJ0=O-j|#J`pLuf(u0i=Gg^J;H_);5D&rtwH2yQc@-p{f9I)`pSX&IT~M5$>qU_i zDGMg0+~6XFV}wURN18uDsz%>CNbdno)1cID9nXb1-+?qI41l@Aq=q_~L`$#_7*zbi zFk-3_(tkN(JfZ@Ijc2LFN1m8PV8JGk%{xgcGZ0fE+QTp?`6#);gv>&Cp@TRAUgz$Pf3Iw#R7uI%DWe-DGhDQi zd$jtJNWyrQ2__t6H?S*NCUFNr65P=8+ z%4psN>{xF_D4XYAo+NdwQh+oJ+S7LIeVl{=sOHPjgg2tKFafTkQo5W$YequmDxeiy zi3dPIun^^Al-o&X8LBK?DPaacyA1jzI+rMF!B?#icL_K}EdA!}-F}Azj5sss#gTc0 zDh7FBd8cR76Sff`O{FS2-$AP=NCJJatK&fMjkRh11Oq79YMfBt%ptK6^wWb-eS@wh zbQ^=Pz3?p8ghH!t#iKY5RlI>o{XeDCR#&rU#&jEtkmEUEfokY|D`1D}T0-6!fej7T z5D3ziCHGc0WxU(_6JPr@e*B{%}}cuhoz6^v6oVPw+;ouP)8!2nh)TgtVMsz}Mpu5F*gPgTbNl?QO^^!K_1|Vd`v$5H+6ALza|w>b7Pj_@{&! zIK@gjgvLFaCRD~9@f3~ zU|@b}(l{Ysi~cAFG-ZHEOP+>M;VDWWqBJ4{&AG@Jh}buN0z#LQA9=P5$sFQi=$psI z39hW*En$El#d7UBtQ}xvbK1fXBuKR^EI|^M-IMWYpwVj(t$iaMr|KmD&WN-Ul)kmN z@*$|}k?KVMdbFKuD`X0gO7dpkh}eq*B1{u%4TNdjA*(>Wz%ddkH6J8v1_)Jw*L0C_ zssYGgnj!G3mpPlwi>MR=lGe0BzjaB1c4#z&5Wp@s`%*XQ-z1~Dtk81Ku4cPkhJBg zjdkNi9gvJ%Db&;Tem6&|aB#*P^+?YG{X*ELD8*e;ctT!Icn}uNSB2b8sxVOIaV-Ko zfGEVWN`jj|p+i14bg4ZI9*Xmo79_P1@w-|DZb-ra3Uoma4K-XnuW=szc6UNCw_PTj z0|6E*6fX;T(wPbfI96do1(}G2s%i5KRD_gE;T?KtbTE;KYKwUkOG$|97Hb|r;>~=6 zoTw_Pi`apn$0wRsH~KpaIkAzCZN>+zzAieWr~hNPBookdn25mI|+3gVf2+%#5Q ziHXFLuV|bBXBa(PGVXYNAUNl?Po0YhJ;qx?>3kyJ0v6_oonuAckP-k7OQ$29Yj!M{ znXv})ZDEqf`xs=I13?UmSrYl?%S3k3sW)NW3xb$2Xqd~ zya`5CbmimoLh4H?9|Hm_g-7EklPwAW0z(2t(x-eH)e#EVbqVlsKg+`VEw7<0%D!MZ8W*N=_GbLU{n3 z$^}I82d6^40RY96D08WMBU>gM<~I!USX*m2n1uqZ-6KE~@N^*OIUc-cpgZ;ZF&z#5g$U$^!I> zz%b(X=EO5zK7|46++fQAf~rOu1##7Q7gj1^2Eqc>I;O-*M%TOkC>K*cE>PCW(R>b| zUW(ihIMhsJTTV9O)nIf9`|D=29?=*ah}d=%ZN0e_-I)lM(g5ZyLAlSlRbj(8L2Kid zL$l1YU4-muoIo}bJ|QwIFLqctq^6dTHTX=Z{6UoBW181MUZekraE0O&C{N$J!i209 zBSRk5oP5J?u>AF+Db)ze#EPDh0Iw?Y;Fn|xcR`(fBQe-UxKi}fSkrLv6J5~eXdSa0 z{~?~2hUkm?so=r0(Gb872vP}a07;|Srz;JxD|fVyS~rC7ViZwlb9^APBQgN~ib-g8 z)%Oa-7)0SqCqOUZE+}v-Cunwvl3jE~f&=4uYZZ#Hb9pU^+cnRkKcon^cLUu(8PmKE*O(gp3umU*T=1+hW zQ0_!Ul&5cR^%C@?E|L*|m34pE^KM6T!zx5S39`g*xfG0j5UU2^Yg|^!j4dO@Io`YXDQ7 zlr=0~yl*3SrXa~UhHSWvY#sobs;J=w4^&;&98DKk?w3acFrc=102}5yc&>m`Vb|f) zz&)rWXRARmA=sv0ssSqA;FYaTD%ik~P=JTo^DqfBaQy?9oitRLiqKui6>rosiwVT{ z=&(l#Q?rlsnIZREy_N=%(^}&7se~Em3e@{9dyPjM^+CwLtxNE06i*X86DLZbOV&c| zoM-by6;=@LIV_2JOj(0yfx#Tm9H~a4<6X!Ksb;4T@e|N}3M9bWfFRV$g#4iwr&<<| z94jCtl*$A(Qtm*WLcpIK%%o_?Iin1QDo5iY8bEnPmEG0}@nSoo*a)iS=7oO=1Bi|k z+9do^O(#Mtj#y{lf&q0CTJxfgpz}$QG}pwV^jQmCQPL>of~Q-}K$OSrVCX^g)|H!CRN|{g6b#^7eLs;s8lls~Dy9gw8s5DSA-iE37%UB<99%47MT% zpYU9}M}dTy%$g+-5D0G}5y6yYk>jAvqd^~N7ojM40QJx89QnO}rm}lv{B@&9~ zp^&V5Bc8fK4P;PBswD6e^?T?W&`~Pwjyo;d)ZHibRlpLgHOI;#YJ#)?O^=5lf{3mZ zpoPj*Sxqg$tb>d+nM!!#l{C+n2$Sp3b{cuOop(>z1-XJc5%h~^2e6A5wH0EaOGvF| zGbrn!w21_(-cp%o2r=pz52CVqhvG?d>WgrrLlS=FgQH#(8=y0grmExid5sUU=Ir*qzC2B*0Fg8L64-~CmMh#PJ(Q0 z2#OZ!xWTnb7$6jlyhb|7nxO!)Kg55oR>hW;Zyq2jBN?hZ6hlYUq*zBKIG8MusVu=B za47-GT6hqi$Uxybs@F6m*s7F8q;#m43?tIPe)2 z46_OJqEfpNa}^0kneYYDhY*2x3At&W5gm;|4w_Jc7^&P$B;eRcHv=WtIwCw0De7TB zj!3x9s-->D2T4Pyh0MO4tJe*hFllM?JCpt>xf z=hDgAij;`@2(_{7wDx&CjDtuyi8NtszxE;aoos_=80e`AkGwud0xZF(YQN!?B2>g+ zP;u3J0Go>@n5cO+qx&Jjp-DcL&mp!0O79W3X^e>nAevL(nfZ0I3#;eX&8?kZz5cLu z8`d3t*t+$rvwRVzGgnUMvy1g~p3l!t&n$M@)7^5hSgdAS3qr}p{kip_F+hW&K0iI% zo|%cE>*;#FZcTR=yNl)A0tc5sQQpMRvvV_A?&qh=`Jx*`FHX-a+@Tj|xq{rRXBQSW&bpf|E=AA(C z9kZUDp3P^g>BYIlY;G0}UtiWT7o=*<%`d9?x+9&*32__CjuW_8&URWe*<6+_cR{Lh zW?^9&xm8|>6EgqT zJ$&ss*KmHmoSp*-ZXCK^n8~JRLwzdm%+A4CmU%)t`Ao4;Ewa~h_2f3rOwTUPb*2}f zd5a4R>I-wt8UL-XbzRKN@Jw^l-P!uab4`QKZJaCS798&G;^J~GpKktYp`K-#W;$V> zoRt{~*7S^k5|CWvvk6>}P0V_xoSChek6FQStaZoXn_ZYzc+Kj$+3sAixZD#`&u8_* zd}*!!`mWoD+Oy5)3T&U9Pz<$N|XKfBBmGBeYi#iUvYFEhC^v(waG z{iU2)1Wvi@v&;33vkrk#O?almAN)19adtsVb={en=GW(98%?;y)y$)?%x1Ir{Bz5l z_1t3JUd#mxGwsF6Z4{9dGd#o0od1C)m`;UduJwFg&n#*mniiX7F>AhYE}xrToSpB? zhfiMY%q@3lHKJm!olno_vy-7?Y1)esJ#&YWyeIcI%LUm`Rh0U(y3$idfQ*j2Z z>DJuD6);crjK9pIqOecdVmEI_J#Ky0i`hbVW^pm+Wo9QH-8PoOVN{*@x?b!SGYiW# zPm7Fd*%so=#I#!*lOyb-67W<_%;5ut!L1UVIdeG$DKwX%?4BO0906SQ$I@pz&tww`Ip zuu;I=!A5~xD_tZ!wRGbsk;l`s9D0Aj>Z*3&1w@|^(Gv2rjkkE?C_u`1l9WJ)F$fXF zyT2qVz>HepM$Mth#!;|VA*8{2q?KbXTKGas&U;jt=?Ng$jiUe(DDF5iDq6WER)&Mv zcxWJShGvLl<0#PrO*0zbY$c}-0FFH-unOfBdqlSegUPe45GUOI#MMQrM)-;cdsi|S zqF4+wx`jshICUi3RoA3XA{1KTiF3iI282>qU(sB2(NPOf8C%gPQ6W(CR$E%hx)7%# za)$8FHpCul*j`qlIaC3FjJoJ$n&_0%IItzeHR%4+^qZ;AIs z+yK*|uSZE4-weAw@w^lzYQ3is;@R(wJ^-g5w;q8j=;ew2NYSH>!btsiQrPFDb(B*g z52|xWN2Z-!s**&AhKDhzdvYPp2~4ec~uWRHQ9Uc-(+>L7)l61hjP>+5(xx zQJjyKqQuH#%uw&tR2+?oD>=LR+!;H;;fl@+PMXe46o_aww=TM-mB~~%@T6>Z;<~7- z0S=L-RY~I1O`6rHLFla*tD+^2g7K_V7R78u=f!g}`1j;tRIc+Fym=o6f#dMKQB7YRNwHRIZ2BfZxBmI1kE9ctRFaWE&pNxW;^jBDyD70pjAvAhU>0R)0_EquV%&4w1jY7g4Jqw5~6ZT!kH1 zagLX0LsW%`e?pON<6Pp&5z#u9C6aFJG#ZCa+01CsMUi;wbVv($LugrEqj>_$)(fk4 zIkd}DV{Z{0LM?gBWVY`XnOl~9L5Ml`-+)93?n;0F2 zy;V&mA{Gzky3?WOj`D>KM)Dyw#?*d`wHb7+i2EZ7Vpt~qsG3Xr34Mr@vnwe_We6>{ z6|FAn8sePOu4TfVrWM)o$`im2lMFR0ado?x(()|A-cspU7t$X{b~vV_ic@c+{(Q)Q zHF<`;o)whKi&HqKV0mmE^w##jC_Gisz*rT%v!s!)=6$9qy^66}TMjP-#wwUG3 z)se}3^w|HhqlD&eJe5i*Vsa~mMnUZ} zR9uL}EHoqnM}*-C&c(UWkQZ`9p1%v7dAf0FbD8K=U!K>cQ`6R>E5_5}lZy9L*Q42)u5RimtO$NKk##Ym_%4Ja zQ22GsN5-FM8A6wdG*rA8FhL5?=t*@^L?K<5BzLy)258wAzC-GOi4lWyOhak_Opc;f zg2eG;6hb_@hv*i6$QRbou_rx zpwO0MVPuX_8778m1;il{M)ycdJcz(r5%P-zqlH!P+R$-?9wa=Tv~hW9l*r1FJXUg% zs5gleLV}1}iv*HDo0Foh>O71468A_!KIGj6z#AdjWfg~&^mJHv=BA0ZD(cUc^Fok% zZo2HwhQ2W#R>DK|8`p+<1;<2hU?rwXv+`f#%qUD#DAF57$KSv(%8ai5fS^<=0KuV`Hycdd>jkBt(yAjCmwI;uP9k4R{y-?&E#W2Ab? z@~vnt9^68m(u=*4QH1ZkP%c8Jp9Bu%-L&X~g+gwS&STo@(r2NU;Zd{|&P6q%BTlTD z-HE7|VAEp>y{H6gy-%Er6SDFf0L=-_2-Zmp*aUd?cqBxK28Fh|ghuVjPGebU`H5F` zwZS+qk@?AF`9@xr*-88&al0p2yhuv-F6Po_vRnl17y^>iuuLXSa6$9Tiu{0LtmaI8 z6cc6BFnYd-{B|e{v}t>$ev$UFF5m$cI&rhK5Ohh&PotYZ;T^z@tJ?xnAr%FJJ+a`C zC250?2V-@MB?g*K|6jo})a1`p2Q;|>;$x@9z{F!jYTlRBiIuo6R>0#Zs?008(`1XZ zc}b6`2uE^FoInd--ba-Xg(ntGT*AM`ofaE-^C4b$SV8D4RMkc4xm4mklk>tXY$|yCoP{=2!T++Yer`Dy^e+?;ZBqOku)j1dC66Bn@ap6 zP@$FD11`jH;vOjo!T3pKGsYy15@TL+K+?nzsgu`-BMR8oA1R@daZD}Z!}IBL1#qWX zHb6~q1-b^vRtr1IXxbh{b&hcV3S%kS{X!m&hoz45QY#|q-$w?-R1oYX&P8EMB0FAL zE}-u=yldsit^ig-Ad_=4k`p}vs|y<<<%Z~~=HLyU#4lo>RKr5ra|I<#I*9SCsw$g@ z3`CjBBMHgRq!ON5T9Nf8f<0gXaxZJd8{QK9@F;XH#Az+Hi^O$tWcgSc#$vk|1qDn# zNo63O!RV!~u7_9j)jIXCUU8kb4gxzN9TF9t1pShU-SxnPhcl?dbB~kA10qTF4W1J6 zn+bCLlTnlyQm2JhYR#62RM2tgFut|)#P)asP@v!h5s?X;*h za>wAgsT`(~ze9PCezk6P#J?DYu?;C|Zzg$sC*hHjjXP~7#2Ctzm4So@gc-UaOfZ3p z!x7XFI~2G0$V5*9^}!bp=@!zh@kO{b1SdiXm8(mT5T1NdysRj$Qy^JMj48Dzkq0!y z&X}BCZxtv2l6P`D>jc%wjdLOTP%|kI*%idQWbE7@^%(djm2<<*#s6#9D&acIA|r}FBIvi z;f09xN)Q6EWjux*%BPb(>T(A74iZ}32MFnDdlYp_icsC5;=MH zkU;8+5NofAc=?(1(TWgA+L{WLBEgWUTsC5Di!|tYYi7Bz)Uijb-*rBE>BxY09QPI_ zCVG+FK20mEl#Je?H}0D2v9}CFAyQ?+15D~z;#GGM{Kl^-fT-p--pNf41eUCW=@ah^ zc~Els19c*tU~9-IQm<&f-?z+2bBkh|4jP?j)<{RjfYgzH=KZYkW~`A_S-M7j%61M! zp<3KrSiN~hw=$4;00a!mF54@ck#pV}1POv7a{6K~osnG&rFoAx_B9IG~H?$X{IQptz`R! za$q}r{0?){96oLG)07e(Ii-xbWrKm@h{B5i<6LXxn_T{DoJg??)A*b>A~Z*w5^ z6b}%1JR;#Wp4%G8q%fluo>Fu?H#JbikPvg;Iv|?_1;N`I$g|f48A5q#!c)n&HIR4E zb*_1O35L1sD-xeJj(5-mx8UaHeDyYGWCuNc0dAVdO3~lO(*ccbfxs zXgcdR0PQ!I!ES3H&!iL9wcIJZt=k;P8+2Oh`0WHx_O=F+hY+~b9Rdbi{`OmJ(2j?X zRJ%0>Eq@?yVWHb2dyEK)ClH)B`DvBTb#S2fmp#jtKo8pFKq_na*8m=Zrv9`1PjjF( zS{G#Mty026cblA1U2;f$jd8S8;l9hC5y5*DaC}y3Bpzp2_CVf96^APLszwIMcKHK& zA)+f0(IDrkgoGZAO|6kCIzkil81da9Sbj7j4pE+of@$Gx@dV-8P0q+u5n4Co$I<(o zP^Y@BfgqK73iT1HkuHC+%}DoOcN9Ien5xSkXwc$4JTF8Gp6>DT59H)YQv$IV2VQ*5 zmTzilU@H)kaKX^V16zK;-UnyE-*qhr+B8rsdyRl~iW1SMMMDE>(oI2&u;;Xh3iB z)8uP})lojNJegZwqGZpK|(IwR3~$aPz7 z)VuL!L?cTEnt;A8z8+vg5DE2@O?_GnqzI3`R!$zcSoS%><3PW58f$OTwG#BRH#s9g z#E^^*DKb=-&2iaF*T@TZ_&f1f9wmhYcJ8KTq$2NOCsZqLQh)98?GX-gkjDs>dwx%^ z#IhG#I_u?e;t={7Tb{*9AIL)-OHFO4PC!^#^90@Xo7ZURKw;H7Mh4PM zYRk0nYr$Xsr!lqO(4oWMNwRSj*=hkqUm%rG{_p};~D9hS$53ixUQ!xrjR+3z?nGIFNra5Q3bC zT=0|FmIUg{&3A3-K+Db`Z*8CsIo?o*amts!Yi8s$_Gzc#86`aKv&m0$MLYqg(Ig&6 z-JCRQ$)|;<4LMxuAE=@Un&q3CQFz)uF$w(Z?m$2Mve#(&cdgx~e(Z6)n*an6o31!* zV`}w-1WkaOKh3M|OYfRYT_c0G`oZR)$G0+&_o751-msU~rmxY`fg-9UK!U_Q@G9SC z0Gdk&a++zn3i?r(X(%jvMw4@-jhHq>MrmU+QcD9MZ_R1rsIPCv zj5Moq(p2&`4&-F(yHh)?z3|uFj5UgPcK_7WhVQ2|3O&G%-kj}UI#AU6Cr^2fi$F-y zKW*tib}im-#6v1@T{mN~mktzW1gpsdjWY^Ho!t*Nl6do1Z412V6AlpBdW9w5>s@a@fTRKn# zTEufnn0vW&bJ#vh2U_;_-`YUSg2t8(v}|{@WT41J2A&`sRh2W@-i+jU$v^>&Ye7)c z5M+CjKK-sO9Y|kD$bJwXi`I_KxU@8N|2po7V%1W6q}WG4)z-0{>7TZAphzJ!jni60 zvDlnS&C-FE4QVVLXyT5d&3iELGVo?N$Rz_!00goY-oivKPf(b<*^De5NLa%{iH}K; zb%&dQA1xhd*%ngmLhhh#@=7b{V`{PxtDd#p}Ig zMm_yC9R{3Q+Gd2uy(K4`&5j2}bt{SC5`-ac-nFFzMVw7b!K(^wjM&Xtqa`ykkVdmX zbSUcO*bIBLbVjZS{X_I<6b-!>>6dotK+BHUmJHOV`6=xe*y>P8c_;Li#jZ7pb*h_) z-V;QN9$jDS=RnOr{nH4=0(p=GZ#j@dht#$8Tzo=T;mtDw5epeav9}yZ=rIpK0h3x& z`i%4@4v_lv0B$6DM>&H8Gf6L9GYS1!H_a%|FzE*Img$W%@<=X*r-4!M&j{HZ zV+G7M+BlGxtO}2ll6z3j@>*~DK+4vj86w@$n`c)npO9 zPP#oZ(2&BeGWeg&h=vLb$8+PG0gM|+w}M2ZqhJK~!Cv=HfOb1}oX*5%>{_YUSN}aJ zm%xwEytI#aMZHk;*9^HCpQheK;Kr!|hv`&B(4feJ<<%e49N!F*j(08yuMv8Ye8f5G z(t_`Kl3uxAxi>*6_vR}S+It~+)}}475@-yv8v{wvRy>gOwc@vLy0qMpI|9=T*^14m zLWHcv|84Kg<87+<|9`sNCgny-qasPeW)Ewvy*E1AqjVD)n#mqH2sx%pxs{^Xbekl) zG$%z!rC)!EQYwDIfyEv5iBRz!MIjiL9xLIcKDKvoH)K)l1iYSglxOZrE zBkzm;F$PB>8O%+>q@k3gL4^;xBMP$d6^EP1sSETSq4gY?iBt=;p9Jj4p;%~)gPxXp z3W$NiK%7a`YkY1@#YK-*eH}57Gy5%qxjf3guu>sOqbPy%yGX?_TM$+)&|#P%gSo{| z0#7Oi65&$_+#rUL{(>N>{%WZ53L(B?jwD1T?vIJa3vrl*fv{uF_YC9Jhua6$KFiu)y@eN*fk!Fo=k?7?`Ip9Tx=*{2~KLS##KV-6{2_N^-$4=IC(mJuV~ROJ;~M{5}PrU31ULiwXA;m`8rD*eVF=kxjUfvvaGTON4Ey zpg(E2aLG>KnvLmb0`9)cm&uB;btdc2sFz&H+mXeI#}R|6cq&nRH6Re_ng#k0&eY%` z11M7>XC%snf+B#eL4-*uSW}M$ydFtH#8IxwQt-xcz(II0rXZ70G2!eIaA%C-$%TU0 zND~;M6~Y(5BSk$~X1DW+@XIUAQ4ahC@S7_ShO>p5n-|?%bOeB&#D!Z85ud`cAK?N- zt1(zn50~YHg_J{)qw=ePLX5`<#GwzXA1MqJLD)h#o;jlcY^>sZLXkW`S6)}&z$Bq) zU+GA*13?ul|$K!BgPOkhgm zxA1CIMLEo9LR8iP(6ywY6flrBQ_pdMFE}(uA@TL2P=i3Nd^PG3Vl;uCHMSby$<#kT zm2ujkakRg{4stcoYh$mn4Dc-EI#wSj0uF{fhABt>^V_BBp~{2VB4|%D#37!HV#dL> z$C9+b_(d(z1MUcbZsCat;Dz}?tBfPkLT}sW#uSq@r)nKQ_};9d81y*xB!|Ng?*Rx* z<}tz((YD9$iJAZ$YvsxIVQx(Gkp!#q)yO8TS0;QpDg~*48Bj<(X=l^=C}f6$K)(oBrbVn9tQ}D}wQ2OiEDVI397kcrSCf*fmx2=V&;XLv zj}d&VU>j4tBWaerBovCH)8}U)q4)}P%fb`E@B+k64*~%<^n{2>rTm7b33rGEk_8=7 zRBhlv0QJkolY)-6b3t-^pfup-%@Es>m^%vEn-CW}KH!KU3kwp1F?Twhc6ki^<28xl zlIeiqlH47Vg^}=NENl}oQxOU7!nvJuB|$$8MJ$f(nEGvt*Na1I$tUf8p!H_qg|i*; zr-Oj#HRDHRryr)nDrb*LCU9O|4Fz8aNCuY5SY*b_kapu0rR zAJqwr(vbUP^Hb?$3wpV?)ho)h`5ju2R7IRQ+)x@!V>9gXCj)+$W+VfyWH#k>Wzry0 zrMhJJEi3_pZ-%)U%_od z%f`a}sq$`<%ocA1Y8-G<)#oUVmI5a3=I2M9haE%26$AO8qF`O|`Qz9E!aWp8aI=7C z>;Bgy-o`w@h>2w`4Lph^R03n6h;#(Q>X#$~>C{;;WWq{7isf-yl^V&vL0CYeTX~K& z7M%brF`&uc|Kubn;UbEJo{`&vq6Hn-2yIp!ye0*yB6^dWFOkk-gy-^Rs4Hq|kKdIv zGMXz(wLtxf*44BP;OHN5~vyu)zYUb*%M#zqTh3GrxUrk_F#7;<~aCW=ZdjXn1k?^Dm zAq+ap1n=&kE9uUrU1>-F2~WbE2x>|7r$WgPTr{CzmGwlb14$CXl?7}v%8G8v5{Ug$ zu3<%?YXH>_3ZN0i4=|>lzabST%43|doV-$fmu!hJT>RKLP}Hk8S}+6qEUmlJNgqiL zf)=g>nT;zQ%pw^ovW7_u3zHHwIWCX~{6ly@@(Dl1s)$<$rbvvcKH*&YtR=A}&Yw+o z6K2@Z1{D)pUNqp@z*Jjt5JR<#S|#VMh4q9KqR6Tc3g(`O#t<w?$?_mtd3O7FReUKE1!{C*8guGtmllsAWDwQBl*DrVHxL{+!(iEme7TBKk9o&wVr4`7 z73(88;5aAgTDir4JLJWfS^e|t($~b?gYrxMtH}X}u(~@&U6N6mWtdmH|OYeI3#Mb!5}x_DEk7k4ILeTuJb@L6n3A10!N0>YG*vvn3v-1aA2) z!fpcy>Zcn--`hgfoq@>z2-J%0l@LsdRcfqg)Razg6QYD%wd!uGG>u;Zmzr26R?ZRk zkd9F}>Me$PoWv(X>6w(mO^zjHATJm$qPZYC^%~7G^>yTD%ZmJvSuE!|vegHGpoy%C zc*EvQ6y%eOMG3Gn(56K3>Q4l_-kD9yf1OJecengxFG{y??^k$4LoE*iBm5Co!P2cq=s%&Wyh z6juTNm|FxqAc%R^7CiyK1=(j3B!?Dc+zs?r5J~x!ElePeYEm7#+ax6FUk#cz^1S%Z z78^uly}q=O3c7q?NnBdiop6zfqyPjP$>&pVm z^`zC)c7yGKv!L6Iw`rZ@$A{tuUWxc*S~%PSI&*?mXCeR36*o2U9W}6Run=$Kv1}N6 zCP7KllXcM#&bkuhwJzP0ar+WRhOk6E!i67)sugu3Gb$l<$8t*y|KdS(lhhX`V*pDG zl1;+j%GYi=rs8qBjBzR)>H~%8+tT?%*pvg8Y9z2wWR57DQF*0MqCOCIAqe8fNh2$( zTeNWkKyvt`7M>`|TcG`7E&mauuZpEKDj-0pU&9=>*a?aAvZlh!A1rxWfd;LU$l4i9%1tjsSgI3sDI> z!l5SupRR$Atw^~fL0f2}}DZbt2Z6f6jk>Nlo&l1d05a}pUQMIK|P8A?PbsDQf zl*!^Tyyw)nh@YA^nL2bE;!(kGdkMHfs{1G|qb;T$OaS}l5}+s@Mjl(5Egrg?6wW2M zHKK-kc>@b1c+#NkX-vp@1W4YCys^+Rv8cCzDEI5Bq@E=8^Co=(Zj-Fr6_kI{-5GyY zr|OkWs^e}Tl5q2TDMjXsF7VXEnlb{v7Y0N~{aO-`3;gdWdNg?hQ}IQO2f@9N5~x^V zo6Q8Q2SSu?LlDez>4XU6#|12}b94qRNQxE)iWNVQX!T%0RH2(75iWo}t|Tai0d?R7 zJ!pTLa)Qh7Wjrn|l_q_6`_eE~(wSt6HIOpYyMUsN9|1;M0d!5ojQ}1i8S1)n6@wt^wFf;FkHT8x#=YTOb16 zsLNRZUtnj-B^374zTwU!e+vTMgaM6)0w(2Q3r~boBqHbjiUud2KR}Bs9HTD<21fnW zBtPa|L98b znuw57U@ZU&Y->yTQ>hH_d;0Z>Y!Y0F#>P$Rx{H~nU7diN8B~vO13)D(5Fya@=h6z> z-BMr)L7bWh`U{JabIDe}gn{6Iz_~Jc6Rag$LJX3QqHo0oQg|Z!15q!fAsL4jl%p1* z2nNE%8$#!ny`oN12|xv-VI%$QiguYT(j2KN2x;(66s4r5M6!x@I~WWN9ZthS<{c1X zsuPQn7=jRs`j2_IacFVAdLpN4UbI;Oo2*3Ii{_P05jK~@_qxFD7F@$c<^S= zH8~|xqC1Skh31Z{r>3O1tRcvMW~KyomNU~E0PaLCt$s=(_fNwWl|*wO7Gy{aC4v;v z;5(_m8e9-eXu}l+Jg%fDEmRs)aiBpK79?t$aAm`0Z&F|&VQ{#@!NbE({n{1`uQTh2 z3(}e0k1c|aBdF*aH`*i)y&CmoLBUunSL-`Do*ld;2sE2Py|>^Yh24sHUT()pi92PX>Q?y#5ERx zi@=)cvyv$hDFN!1h!Ag63j&Ow_Z?bCP*a?lY|2s2tRp5CEe{I6^tp0%63n2{1p{VG z@56$;7+x=c!WK~D=(Uq&=pP(2n<HZ2JM~0>2}!Hy*KvPval_k@cp07%7cdvG`W(UhrrO7CUC3+g zQq*bQqp-pfFogveC`Jt@8V46dkJ-W#VYUFey`t^P;bu!ZlNi#6yIS0jR8mprHUov3 zU;)c>*A}4)Myv&;oVK#5nLsmuh|o=oiDsg%e%l6#so@eJk)Ho*w)IH}uAqaVaS8cL z&TZQ`KvV@B+H2e)*hm0EDfWqIOFMIn1Q1xE2W-|yq>3CvN)ZJH9c-FBR`V&C~ zjDbNV=E}2M#OH>vUrd1M8*y5UjgqY=NgqV4mK|pGb|uQh+X}O(2*cx0CiDh zKpG_ZbmsF*gN*LKZk~`FY!N>q)ztnW@u?T1f{A1*snPlIKuUCJzD&{;&>_q5hcr*Y zho>xn34*C0EHIt6Uot>1Dd0`Jl73_}sw&QWJt3y~k-QV=mvkj4-}oQ_(RXEm(eqMXPr%&KsiMACICA9_z%_?I1_**k zsD*(r+X@3js6q#|b-%%jryxmD#3b_4!ijhxO95}tk!DhVB4RcUUAzqow+SaNN+9qa zML^Ype|rdCEA@3Uq{EqFR;4G79ec|#sI8&6MAt@ym^lM%a@<1sfxrS6JCG<#Ir7!u?$BF_V@XRf z0U>AX#84TGqFTo7W`PjSnn5CublW^egjNjW4NK5)%!sMSk_;3DJzi0{p*jMxz(-mG z5(`fc^{+&`>12fnJVf9t?Lnc6e}DIM~Sf zLHmiqR0U9uf#T$ak|e3$xb)W1lnqxB!Q@_}7D<08j-Zx$;2w(NLdx0`ptBSc=qGT- zl7g{U=;x^yrPx){`_m1C7IJfh5s+*%aurczvaqs|yp6}uBB(+svPHU}ZcGn~PA23; zc_K>wz(SD%py7t#h=nK04-_hXAoGyN#Cd8O>qykt5O_xj+X4s-_a{Is^d3b4LJGh%A;GDkz(HDouDkNV7WSe{ z;E9By0S_QDs_ljb;eiK4mQM%2WnrKQ?U^W$9sUnbgfc7*Q$#&caK)KJ9p8o}02*Ez zqa88Oa6mCRH%FrHu0xTJu_zG+R38XsN;F*1NiHNe zgnqVo$zh$pNWg}cmq3vTt$9NcuBJu-*F-%53>)lz$s7xtR)WzuxeiDkC>$1Q+q4Wg z_ceDQDAO+K7QrN=z_;|#wowmjU?4+@0YYNpBY??9+&JBd=?F-7^~naz0>v4#ipn$F z78(J=Um`0oS_Ip<5~ka+T)1TcvWLJb)o^lT1ekO9f4kx)BC!Pa7i-)6 z!6v+9;x!Q4Lh7?%!NNeq3#d~Uy+z_qO*Se(kcWIl6A3{2I<$`BV!0ER12Om2Y*QO3 ztUL47u*h*{9Yx&IR0o!BCdaI7j%{5~)6|94_nLSuIkJurGbSuKDCiJI1ZNbG6KOiX`X>e|H zTEIa@4x>s3L>_#*1;7p{es z5U!;9Kz>l?^rLc~BGN@Bw?+^Kz63RL%n#HDA}uyVh_EW#4wv&VST`xzS{MkQGlUU@ zIh5m(*v&<_`XMuV+!*Ic;zx%A;x$-sq0ImQv+!ymi~!(yROLqy8`FuVlZXS3`m0GN zlKh@BOFqq_q=C7Bmn_~JBXmHcp{dNo6=L!!W|eS ziUuJYqp-4hBF=p4UUv+H5;Rp1Gd_}msQLzF?5IyYhQt?@lmR~pcpAA@vJmP)mgou8O1OYB@r~6igaW0z7Gh`v9N z8$?q@TOh7}_cIWMaO$#V8Cs$dJ5DJS)^kMUifRop7xo4ep$Yv$LG|*XWaV_fBNc{u z#H36U{VgVre^TFTR5P7l+@#FVh9xDE3Key3&2yS zacGO6L`!)cC!@j^k;D(}J!akqs#HBbLeiEQVYg}GYQr4G(Ca{g7CI5ft)?E;MnmER zNorQuYl0qwvgM12!x)7U^_4Bbxg(Q}w~jMg1i=Sq4mQGC5;TokUG9XGE8A9)1^uun z5Pvu)Wattf0j!DPM-~$mH_RZ_C!70-t`+{jh}#k%lXdYOIm5vqgWyt+YLPO)KVHN? z3$Mme3!B)wJT|YTv1a_HJ`(QcZe*5`-l9J}n49FYh}qqK_zrC~p(jUpQW`P2|{U+$=-_lt8QwK^oBXpO1Q z5t4dXp>S{n<%vAlu0vA43hNofj3WTEb7l*CD_FjG31Ui&thj|1G$2o8R`ueYi3EmIxYZU2-08YsnoX!TZ%18CqkFd zk9rjO9(f;yE)=#5^- zTOm(qVUD<0c@4Ud^=S`(hZ!=BHpM7bFsmgiJw7_V|HVSx0`S zo`}YENc6%H0L&o}#7TgEhBqEYdr3VxjuD&F%l4ajKi9qVBsgaAw*n}yzK)0*w4C1q z_%2RJ79;RxdeR2&%}~=V3=|c8C8!#OAcD^iWt2KORYV|w>H|T}AR6W9FAQXt6A8)> zOAa_pW>_PjK{V0`ZL|n+t@;zOh|vw9k5PD{7&dgUWMys9ow6|55wwi3$EC)ak0F^O zTX5JIT+BGAC7+-BFo)6rqXXSaM>vQEvO4w{!yvLp;t%tQm~6aA-K^wriTL7(jq}gy=Mm8{7E(fqDeN_=utVB`*o+U1Tm6DW0iUdfL^z7C zlH9a*jXKC2S=gxQn=zy`SnPCaP)~AbvH|)yGe<;aj_iIQkNCcG0u**XWdNxqMmSeA zPFnSR4I4rq$VEx5E00>u2agH)323Rxhd)JQu^i{q=LeGWh)y=S1XlnpL<<8Qdaw!F zCn%PN*x7Idt6xWP|DOy*%O49ES}qoBjsI*Q`jN1hu@C{zSk$XgmL&N@CmUyL`vXZO zLe4o_BXLB?iW(?}r+vid^KfMs@k9`6#i~6>{Z6t;`%h$7ROt_2O%8vKOl0;93?!|r z>}u;snouBQ3IC7|@TuQxe|RDpNa8mTpkWZn_UDL!;w&SG@_aW7_9}mPA}Q4q{dti! zL}0myCt{#5nl{uF@z@BfN40Z4s$C%UiW-Q3mgv}zDurJ?V6VO)WsX?ZJM{-#wT=SqP7V2(Ww1761jMqb>J<75gn6F z+pk8}pI0@~FteldYI`7vLu4hK`%Dl_B=w?i^*N#-28M*!a-%=2d(>0WKMVwf0Za@v zE{*}+(s>jDrx5{05z0}jxbDf*sAwS=9VXSaFh|t)==qzesO{DvVCX|~S{Wz`orQ7) zrJ%5d7_!H~2!QY!%hL}?5RuCy zNFriEMOzGaVhgxh;zUAq(#S&jFef{Qpd#F5G_@=U1~5=HJUl_>#262;(;(wt8kzN~6|(j{02+=xb^`d0&zSGC9@xrn{U|4#-&s~7J$pv|cC z6!k<=53(ORv`Um4><>f=VK4Hp{%VM;AkGnnbjo3Ap`J~d_Rz^@_oF=ziWlnIRnZTzqdGzf5QS|oC>t?)V*eRUHn<>t!yGQb`?V0 zl_d>o(v-$vAL(8TuNJO0*&HMGcK@FYWUqkmCj;SWf#MjvQ;!9b7g_~}t|R-CU2Py1 zq%Hd5AD)O@jileeNDKi3E8#KM8ciCCDuvHaR~iB z)#s>MgGKIsdz-e}K=$*sY6F3&L`X%W58$J9A64(Kp$UG-WJA_~?JrnckwMTb#Myv! zs@@_Y>hu7B=&z&rLQtwc*+kXOoY~NM@d5}1F`3BqMLhJN;6O!zO5hY>VUDD_UZuZg z`xZ%{D~?g9*A+-7G%P~{h)yMfLDVY~UVjqNsh}^ZJWq*4R?8UKl#8qg>lxshuB2B_ zyC~U!9s>3OKu&ou98Yv_#vOD86QZl>^YLUs-RGhopl9($@MbI^T2mD1>Dl>kRmtjj zkTF;inJt&0XMOGrek+-5TKUORsU)B?x9$pLb-ydAYxuQM*>rh~WG0IZgdVWq*OvBV zG~AGbE>JhPqv87E3V4%gmsiW^{uJvyYr)wvo%P`ylJYZ2{-8@sdXugMv;tSs?MZo4 z9u1$0l=5q4(&GP=O}IqqA6JV^20b!9X(pJ!kS4?0@Tr%nGnq8UK+xxMCjtycg~F9^ z8!4CHpTess4TI91P=2y(!k6&t39k!pQT#T7sSJ*CD5d#PE6DnIJWW&UF_ooTxv?TW zWG6{DuF-YEas>(J|4$B`f7EcISLxAbn*VFla$xTPg9nuN9x$*~bU?chXLtKDKq(Je_Uns%75KH66rK(K(?%3rnJ0syYQLqW5Uuh z441bBWsyJqy(;6RQ?0TC2HlVtTv~QRqF1Kn!2Z43w5c6i3SFirx|7z8LUHz|C{>^Uv z%LbQnN96u%)&983-^vUx%eKl*f0u!YR7PH{`X5#Q?M`LGGySfLmhoN7bF(3WWx2mp z{onlQ-@Ll=Zwkk#@-J5T7lpr=UtQ5md1_GEz}!sAzsUb-=V-k2=r%2fw?bF{hRonZ zm4#(4#Q%ezm2dQaFt|Bn&40w438kMk)1@>`<}cMumpKzkKWnB-X`0Mms+lfxCX{~G zOqbF$nZHysUFJ+E{j8ZTrD-yMsb;#&nNa#!GhIs4Wd2gkbeS`u^s{EVl%~o2rJCt7 zXF};`&2%YElle%wMXRE^{W7e%4Hv(lnXBR5M-XOep>QU#IKnHf@Ik>F=8vd}gM9`obo| znzSuF^SQcDmXws7nYy@hhmw*JXP1=RGP??HZUFDV(~E-Bg3v!tZe!zCpR2TZ(v zalEADsDHMPhPn>_;=3s$`*m4+`qH**XK(&&_D}CFx%Kwj+nlK#*Zaze4_^8Bt@8)R zVy-(oL{@&$B=pO+uD2h3@uN#V?XdFq?N9VSX3oFo%@vpm#NzKQNpWCNP z_>@zQ4167XZ2YE^_Ai@v^Dkq|JM3RpD|z(j-yWD5{bg$TsBeB><=?n**Zx&QYHxn; zp&J{tJb3!VxhLOwMAw^^cDk$4r&o?RN$c3|?e3%NymjJ#y5IW35$E6f!Yd===Y4eF zzH9I3J*@H6P8*J@UF(Ow^*bH=+5>05`r8lfPH-Q$=fQ2Y9y+p7%Zn#&I`6Okd)f^7 zbVcc1=X{g?;nz!#@AccP|E_7+efQsnU-Uxf>z?}0n0g`a+vm*Q{NI!N^_X%`GufY}Z$D|%=8_NJJ?et@+h@L=(`N29 z^Xj)c;nMNx9%pRXyZxlO6D}XTv~MvOXb=x?cURp1Aa^SGNyt z-R8TQFaOv-<)8TYahHaU8TwG~u2a50t>@;}Pk*@h`Fb~xTG+z#()0J8TEDdYU#`Du zP_tdF4s`y{jQSUCeM0-R-Ej@h?t1ZwXD)hwX8P0f8oxB5!9`~udDU;-uiI1mM}71a zi*{=perPe}_a%o-|J&%&sq1$~cRt$ht>exZTl<(12Y9w8_kP@M)@$#UT%B4rZ-4aI zT?c**m9*%7&9Jw?-yI&=ymt9SDMb=Jo?3F%3f)= zzNK&bxl^9_=F(}C&U#~2>p{1!O`r2}<{yWRseNqg(qHf1m1#D%%`e0EA2)L1U871r z-|gP|?ynzi*z)MKAE!OlsN}?zTKTrUd;dLh%$0RJPkwaOihG`Zc~;=8=Vl*xdc?0Q zm-Z^#e&a>geRtK*?<_p#?m3+gYyRFPPrm?$beayrQ*0fxe+W3)i|7rX8-QV=;JwH9UZC;<5#?lVIHU7D2td-BA~ zKAtq`slPq9s`q?j)#TepJ#$*Oe>`)?lG1bDxU62^Hz)aSUUm65O@I6MPj~ly=jf;J zU)g8vajhS)NHQv|Hzhv9#11CJQy7u>1UaO)E$A`QrP~;+bvx z&Y1hwrR7UsdApN;@(UYIIQ#8y`PI+5Zg^|@`rVhW9@b&#piT$U`ybavzPoqh@S`?o zzdiFdcZ=JW|N7PXbKhOu{JL4^&oAjYFF5=_`j!R3>{*#+H{W^N37IF8^McKX?>TGD z&Q^^)t9GuA@zMG``dYpDH($Kv)=wJWUw7%$F?IQa!=AZeM|SBE*H4)C%vVpgt8?Rs zg)=t{IW4hn?5D<%Nh#yUZ=Za1M(J3-*{NecsQnv*%)g-Jk!4-3>G{|z1=pg@*)XpB=8_a0+dF`(rYTDG>-SHlC`+R5eB-KD`!>

5iQ4_vAZ0(&x$L(&l{nh#lHkL1rb$I=;hFdd_uYK;> zzVrSXdAiM)sq&}q-FMm>wbm}0xW3n_xpT)G^Ezy};GeHg_d4_g@0xaNvw=JJZk)g3iS2ru zAsfFQKY#XndskdKV$l2XlI&FzhkyH<&(pN`tVsi!r>_|^=$yU>_q2&NTQ>0Q3Cqt~ zlpS`&i{p-XBr**aAOsspxuqN~4w=H~ox#!dg=S{nF$Fy~qcwb+) zV%DVZ4?BGMd1trZ|5MkFwU;M{>17~dp0YXHf8VOx2*o9Y{qh-`Ez}x2*i`c`r=pwEAO~{@F|WeLbhk6VE+4 zZpCF2rnT+-R(YL4{okDU>cY|%ds^4(b|5ivb=j~DbDGS!;Hx={GN?3PC4(&9%>9mjk;%jKMY~~4dm~M1+{XF%h&e!zZy7+`UE;x6}`-5Mu_w>@%eSU2D z{PKN^|FM4Bq$S@yHe}(!veq3}ez5i3mM11&e*N6{E?K<&zlZWE}Pd&V}f8FLwer!_bj2oIC_3FkoyP6(+WuANYoU=+w+y0|jt#5i< zeagpcHt(6VaI?G7i*4tAx#siRT9=>rOMOnocX!@he{$>pm-%_sYp?%!@&m)3Jz@Qz zXHICizQ>g9<#TrRzSh0|yrx}_D?e@fkS}MC{q~XtU4J;(VNUOMrHjr#{-Yk_Pv8D< z^Dnw}nDXPMO>aCl>8KAA!)6?vn)vtKN1VL5{(-@_KhUhzcRQcCxBh|7(RHC0uiyLe zCl}tntp4E_T~&72k*760YVEjtn)GPx#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 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: