diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index fa925a118077f698698aa21610067e02dcb2eafc..9e49ce19dc9c3e986b28911bbd9c929e24cda74e 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -33,7 +33,6 @@ from lms.djangoapps.verify_student.services import IDVerificationService from lms.djangoapps.verify_student.utils import is_verification_expiring_soon, verification_for_datetime from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.theming.helpers import get_themes from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect from student.models import ( @@ -458,19 +457,12 @@ def cert_info(user, course_overview): course_overview (CourseOverview): A course. Returns: - dict: A dictionary with keys: - 'status': one of 'generating', 'downloadable', 'notpassing', 'processing', 'restricted', 'unavailable', or - 'certificate_earned_but_not_available' - 'download_url': url, only present if show_download_url is True - 'show_survey_button': bool - 'survey_url': url, only if show_survey_button is True - 'grade': if status is not 'processing' - 'can_unenroll': if status allows for unenrollment + See _cert_info """ return _cert_info( user, course_overview, - certificate_status_for_student(user, course_overview.id) + certificate_status_for_student(user, course_overview.id), ) @@ -481,6 +473,23 @@ def _cert_info(user, course_overview, cert_status): Arguments: user (User): A user. course_overview (CourseOverview): A course. + cert_status (dict): dictionary containing information about certificate status for the user + + Returns: + dictionary containing: + 'status': one of 'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', + 'processing', 'unverified', 'unavailable', or 'certificate_earned_but_not_available' + 'show_survey_button': bool + 'can_unenroll': if status allows for unenrollment + + The dictionary may also contain: + 'linked_in_url': url to add cert to LinkedIn profile + 'survey_url': url, only if course_overview.end_of_course_survey_url is not None + 'show_cert_web_view': bool if html web certs are enabled and there is an active web cert + 'cert_web_view_url': url if html web certs are enabled and there is an active web cert + 'download_url': url to download a cert + 'grade': if status is in 'generating', 'downloadable', 'notpassing', 'restricted', + 'auditing', or 'unverified' """ # simplify the status for the template using this lookup table template_state = { @@ -507,7 +516,7 @@ def _cert_info(user, course_overview, cert_status): return default_info status = template_state.get(cert_status['status'], default_status) - is_hidden_status = status in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing') + is_hidden_status = status in ('processing', 'generating', 'notpassing', 'auditing') if ( not certificates_viewable_for_course(course_overview) and @@ -563,15 +572,9 @@ def _cert_info(user, course_overview, cert_status): # Clicking this button sends the user to LinkedIn where they # can add the certificate information to their profile. linkedin_config = LinkedInAddToProfileConfiguration.current() - - # posting certificates to LinkedIn is not currently - # supported in White Labels - if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site(): + if linkedin_config.is_enabled(): status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( - course_overview.id, - course_overview.display_name, - cert_status.get('mode'), - cert_status['download_url'] + course_overview.display_name, cert_status.get('mode'), cert_status['download_url'], ) if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}: diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index b55d0afa61379e5ec95c6ed46b2f52370d9e9a03..7b7f5e74fae58b99e81b99c22cb7bb119d4b0e85 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -16,10 +16,11 @@ import hashlib import json import logging import uuid -from collections import OrderedDict, defaultdict, namedtuple +from collections import defaultdict, namedtuple from datetime import datetime, timedelta from functools import total_ordering from importlib import import_module +from urllib.parse import urlencode import six from config_models.models import ConfigurationModel @@ -51,7 +52,6 @@ from pytz import UTC from simple_history.models import HistoricalRecords from six import text_type from six.moves import range -from six.moves.urllib.parse import urlencode from slumber.exceptions import HttpClientError, HttpServerError from user_util import user_util @@ -2527,23 +2527,21 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel): """ LinkedIn Add to Profile Configuration - This configuration enables the "Add to Profile" LinkedIn - button on the student dashboard. The button appears when - users have a certificate available; when clicked, - users are sent to the LinkedIn site with a pre-filled - form allowing them to add the certificate to their - LinkedIn profile. + This configuration enables the 'Add to Profile' LinkedIn button. The button + appears when users have a certificate available; when clicked, users are sent + to the LinkedIn site with a pre-filled form allowing them to add the + certificate to their LinkedIn profile. + + See https://addtoprofile.linkedin.com/ for documentation on parameters .. no_pii: """ MODE_TO_CERT_NAME = { - "honor": _(u"{platform_name} Honor Code Certificate for {course_name}"), - "verified": _(u"{platform_name} Verified Certificate for {course_name}"), - "professional": _(u"{platform_name} Professional Certificate for {course_name}"), - "no-id-professional": _( - u"{platform_name} Professional Certificate for {course_name}" - ), + 'honor': _('{platform_name} Honor Code Certificate for {course_name}'), + 'verified': _('{platform_name} Verified Certificate for {course_name}'), + 'professional': _('{platform_name} Professional Certificate for {course_name}'), + 'no-id-professional': _('{platform_name} Professional Certificate for {course_name}'), } company_identifier = models.TextField( @@ -2567,33 +2565,43 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel): ) ) - 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. + def is_enabled(self, *key_fields): + """ + Checks both the model itself and share_settings to see if LinkedIn Add to Profile is enabled + """ + enabled = super().is_enabled(*key_fields) + share_settings = configuration_helpers.get_value('SOCIAL_SHARING_SETTINGS', settings.SOCIAL_SHARING_SETTINGS) + return share_settings.get('CERTIFICATE_LINKEDIN', enabled) + + def add_to_profile_url(self, course_name, cert_mode, cert_url, certificate=None): + """ + Construct the URL for the "add to profile" button. This will autofill the form based on + the params provided. Arguments: - course_key (CourseKey): The identifier for the course. - course_name (unicode): The display name of the course. + course_name (str): The display name of the course. 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. + cert_url (str): The 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. - + certificate (GeneratedCertificate): a GeneratedCertificate object for the user and course. + If provided, this function will also autofill the certId and issue date for the cert. """ - company_identifier = configuration_helpers.get_value('LINKEDIN_COMPANY_ID', self.company_identifier) - params = OrderedDict([ - ('_ed', company_identifier), - ('pfCertificationName', self._cert_name(course_name, cert_mode).encode('utf-8')), - ('pfCertificationUrl', cert_url), - ('source', source) - ]) + params = { + 'name': self._cert_name(course_name, cert_mode), + 'certUrl': cert_url, + } - tracking_code = self._tracking_code(course_key, cert_mode, target) - if tracking_code is not None: - params['trk'] = tracking_code + params.update(self._organization_information()) - return u'http://www.linkedin.com/profile/add?{params}'.format( + if certificate: + params.update({ + 'certId': certificate.verify_uuid, + 'issueYear': certificate.created_date.year, + 'issueMonth': certificate.created_date.month, + }) + + return 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&{params}'.format( params=urlencode(params) ) @@ -2608,10 +2616,7 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel): Returns: str: The formatted string to display for the name field on the LinkedIn Add to Profile dialog. """ - default_cert_name = self.MODE_TO_CERT_NAME.get( - cert_mode, - _(u"{platform_name} Certificate for {course_name}") - ) + default_cert_name = self.MODE_TO_CERT_NAME.get(cert_mode, _('{platform_name} Certificate for {course_name}')) # Look for an override of the certificate name in the SOCIAL_SHARING_SETTINGS setting share_settings = configuration_helpers.get_value('SOCIAL_SHARING_SETTINGS', settings.SOCIAL_SHARING_SETTINGS) cert_name = share_settings.get('CERTIFICATE_LINKEDIN_MODE_TO_CERT_NAME', {}).get(cert_mode, default_cert_name) @@ -2621,41 +2626,19 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel): 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. + def _organization_information(self): + """ + Returns organization information for use in the URL parameters for add to profile. Returns: - unicode or None - - """ - return ( - u"{partner}-{course_key}_{cert_mode}-{target}".format( - partner=self.trk_partner_name, - course_key=text_type(course_key), - cert_mode=cert_mode, - target=target - ) - if self.trk_partner_name else None - ) + dict: Either the organization ID on LinkedIn or the organization's name + Will be used to prefill the organization on the add to profile action. + """ + org_id = configuration_helpers.get_value('LINKEDIN_COMPANY_ID', self.company_identifier) + # Prefer organization ID per documentation at https://addtoprofile.linkedin.com/ + if org_id: + return {'organizationId': org_id} + return {'organizationName': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)} @python_2_unicode_compatible diff --git a/common/djangoapps/student/tests/test_certificates.py b/common/djangoapps/student/tests/test_certificates.py index f7d5c1d5b739a1ba340cffa8800790c9697a4952..5efa5962bca0120f615f0ae47a7ed1ab1cd07790 100644 --- a/common/djangoapps/student/tests/test_certificates.py +++ b/common/djangoapps/student/tests/test_certificates.py @@ -5,7 +5,6 @@ import datetime import unittest import ddt -import mock from django.conf import settings from django.test.utils import override_settings from django.urls import reverse @@ -15,8 +14,9 @@ from pytz import UTC from course_modes.models import CourseMode from lms.djangoapps.certificates.api import get_certificate_url from lms.djangoapps.certificates.models import CertificateStatuses -from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory -from student.models import LinkedInAddToProfileConfiguration +from lms.djangoapps.certificates.tests.factories import ( + GeneratedCertificateFactory, LinkedInAddToProfileConfigurationFactory +) from student.tests.factories import CourseEnrollmentFactory, UserFactory from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -191,10 +191,12 @@ class CertificateDisplayTest(CertificateDisplayTestBase): u'do not have a current verified identity with {platform_name}' .format(platform_name=settings.PLATFORM_NAME)) - def test_post_to_linkedin_invisibility(self): + def test_post_to_linkedin_visibility(self): """ Verifies that the post certificate to linked button does not appear by default (when config is not set) + Then Verifies that the post certificate to linked button appears + as expected once a config is set """ self._create_certificate('honor') @@ -202,39 +204,10 @@ class CertificateDisplayTest(CertificateDisplayTestBase): # button should not be visible self._check_linkedin_visibility(False) - def test_post_to_linkedin_visibility(self): - """ - Verifies that the post certificate to linked button appears - as expected - """ - self._create_certificate('honor') - - config = LinkedInAddToProfileConfiguration( - company_identifier='0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9', - enabled=True - ) - config.save() - + LinkedInAddToProfileConfigurationFactory() # now we should see it self._check_linkedin_visibility(True) - @mock.patch("openedx.core.djangoapps.theming.helpers.is_request_in_themed_site", mock.Mock(return_value=True)) - def test_post_to_linkedin_site_specific(self): - """ - Verifies behavior for themed sites which disables the post to LinkedIn - feature (for now) - """ - self._create_certificate('honor') - - config = LinkedInAddToProfileConfiguration( - company_identifier='0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9', - enabled=True - ) - config.save() - - # now we should not see it because we are in a themed site - self._check_linkedin_visibility(False) - @ddt.ddt @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') diff --git a/common/djangoapps/student/tests/test_linkedin.py b/common/djangoapps/student/tests/test_linkedin.py index a237ead911853fdd1354b7755b5d582e14b9dadb..b7eee9a4198785ffaae613eb8d3d196e6e711119 100644 --- a/common/djangoapps/student/tests/test_linkedin.py +++ b/common/djangoapps/student/tests/test_linkedin.py @@ -2,120 +2,101 @@ """Tests for LinkedIn Add to Profile configuration. """ +from urllib.parse import quote import ddt + from django.conf import settings from django.test import TestCase -from opaque_keys.edx.locator import CourseLocator -from six.moves.urllib.parse import quote, urlencode +from lms.djangoapps.certificates.tests.factories import LinkedInAddToProfileConfigurationFactory from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context -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" + COURSE_NAME = 'Test Course ☃' + CERT_URL = 'http://s3.edx/cert' SITE_CONFIGURATION = { 'SOCIAL_SHARING_SETTINGS': { 'CERTIFICATE_LINKEDIN_MODE_TO_CERT_NAME': { - 'honor': u'{platform_name} Honor Code Credential for {course_name}', - 'verified': u'{platform_name} Verified Credential for {course_name}', - 'professional': u'{platform_name} Professional Credential for {course_name}', - 'no-id-professional': u'{platform_name} Professional Credential for {course_name}', + 'honor': '{platform_name} Honor Code Credential for {course_name}', + 'verified': '{platform_name} Verified Credential for {course_name}', + 'professional': '{platform_name} Professional Credential for {course_name}', + 'no-id-professional': '{platform_name} Professional Credential for {course_name}', } } } @ddt.data( - ('honor', u'Honor+Code+Certificate+for+Test+Course+%E2%98%83'), - ('verified', u'Verified+Certificate+for+Test+Course+%E2%98%83'), - ('professional', u'Professional+Certificate+for+Test+Course+%E2%98%83'), - ('default_mode', u'Certificate+for+Test+Course+%E2%98%83') + ('honor', 'Honor+Code+Certificate+for+Test+Course+%E2%98%83'), + ('verified', 'Verified+Certificate+for+Test+Course+%E2%98%83'), + ('professional', 'Professional+Certificate+for+Test+Course+%E2%98%83'), + ('default_mode', '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={platform_name}+{expected_cert_name}&' - 'pfCertificationUrl=http%3A%2F%2Fs3.edx%2Fcert&' - 'source=o' - ).format( - expected_cert_name=expected_cert_name, - platform_name=quote(settings.PLATFORM_NAME.encode('utf-8')) - ) - - actual_url = config.add_to_profile_url( - self.COURSE_KEY, - self.COURSE_NAME, - cert_mode, - self.CERT_URL - ) - - self.assertEqual(actual_url, expected_url) + config = LinkedInAddToProfileConfigurationFactory() + + # We can switch to this once edx-platform reaches Python 3.8 + # expected_url = ( + # 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&' + # 'name={platform}+{cert_name}&certUrl={cert_url}&' + # 'organizationId={company_identifier}' + # ).format( + # platform=quote(settings.PLATFORM_NAME.encode('utf-8')), + # cert_name=expected_cert_name, + # cert_url=quote(self.CERT_URL, safe=''), + # company_identifier=config.company_identifier, + # ) + + actual_url = config.add_to_profile_url(self.COURSE_NAME, cert_mode, self.CERT_URL) + + # We can switch to this instead of the assertIn once edx-platform reaches Python 3.8 + # There was a problem with dict ordering in the add_to_profile_url function that will go away then. + # self.assertEqual(actual_url, expected_url) + + self.assertIn('https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME', actual_url) + self.assertIn('&name={platform}+{cert_name}'.format( + platform=quote(settings.PLATFORM_NAME.encode('utf-8')), cert_name=expected_cert_name + ), actual_url) + self.assertIn('&certUrl={cert_url}'.format(cert_url=quote(self.CERT_URL, safe='')), actual_url) + self.assertIn('&organizationId={org_id}'.format(org_id=config.company_identifier), actual_url) @ddt.data( - ('honor', u'Honor+Code+Credential+for+Test+Course+%E2%98%83'), - ('verified', u'Verified+Credential+for+Test+Course+%E2%98%83'), - ('professional', u'Professional+Credential+for+Test+Course+%E2%98%83'), - ('no-id-professional', u'Professional+Credential+for+Test+Course+%E2%98%83'), - ('default_mode', u'Certificate+for+Test+Course+%E2%98%83') + ('honor', 'Honor+Code+Credential+for+Test+Course+%E2%98%83'), + ('verified', 'Verified+Credential+for+Test+Course+%E2%98%83'), + ('professional', 'Professional+Credential+for+Test+Course+%E2%98%83'), + ('no-id-professional', 'Professional+Credential+for+Test+Course+%E2%98%83'), + ('default_mode', 'Certificate+for+Test+Course+%E2%98%83') ) @ddt.unpack def test_linked_in_url_with_cert_name_override(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={platform_name}+{expected_cert_name}&' - 'pfCertificationUrl=http%3A%2F%2Fs3.edx%2Fcert&' - 'source=o' - ).format( - expected_cert_name=expected_cert_name, - platform_name=quote(settings.PLATFORM_NAME.encode('utf-8')) - ) + config = LinkedInAddToProfileConfigurationFactory() + + # We can switch to this once edx-platform reaches Python 3.8 + # expected_url = ( + # 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&' + # 'name={platform}+{cert_name}&certUrl={cert_url}&' + # 'organizationId={company_identifier}' + # ).format( + # platform=quote(settings.PLATFORM_NAME.encode('utf-8')), + # cert_name=expected_cert_name, + # cert_url=quote(self.CERT_URL, safe=''), + # company_identifier=config.company_identifier, + # ) with with_site_configuration_context(configuration=self.SITE_CONFIGURATION): - 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) + actual_url = config.add_to_profile_url(self.COURSE_NAME, cert_mode, self.CERT_URL) + + # We can switch to this instead of the assertIn once edx-platform reaches Python 3.8 + # There was a problem with dict ordering in the add_to_profile_url function that will go away then. + # self.assertEqual(actual_url, expected_url) + + self.assertIn('https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME', actual_url) + self.assertIn('&name={platform}+{cert_name}'.format( + platform=quote(settings.PLATFORM_NAME.encode('utf-8')), cert_name=expected_cert_name + ), actual_url) + self.assertIn('&certUrl={cert_url}'.format(cert_url=quote(self.CERT_URL, safe='')), actual_url) + self.assertIn('&organizationId={org_id}'.format(org_id=config.company_identifier), actual_url) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 1fc4c6c45adc04b2426e0c798e5bd7afe69bf74e..d7017d3bbafb008c6df27b6a150d89a7198358ac 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -7,10 +7,10 @@ Miscellaneous tests for the student app. import logging import unittest from datetime import datetime, timedelta +from urllib.parse import quote import ddt import pytz -import six from config_models.models import cache from django.conf import settings from django.contrib.auth.models import AnonymousUser, User @@ -22,11 +22,7 @@ from mock import Mock, patch from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import CourseLocator from pyquery import PyQuery as pq -from six import text_type -from six.moves import range -from six.moves.urllib.parse import quote -from bulk_email.models import Optout from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory from lms.djangoapps.certificates.models import CertificateStatuses @@ -34,6 +30,7 @@ from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFact from lms.djangoapps.verify_student.tests import TestVerificationBase from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory, ProgramFactory, generate_course_run_key +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms @@ -58,7 +55,7 @@ log = logging.getLogger(__name__) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @ddt.ddt -class CourseEndingTest(TestCase): +class CourseEndingTest(ModuleStoreTestCase): """Test things related to course endings: certificates, surveys, etc""" def test_process_survey_link(self): @@ -74,12 +71,19 @@ class CourseEndingTest(TestCase): @patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': False}) def test_cert_info(self): - user = Mock(username="fred", id="1") + user = UserFactory.create() survey_url = "http://a_survey.com" - course = Mock( + course = CourseOverviewFactory.create( end_of_course_survey_url=survey_url, certificates_display_behavior='end', - id=CourseLocator(org="x", course="y", run="z"), + ) + cert = GeneratedCertificateFactory.create( + user=user, + course_id=course.id, + status=CertificateStatuses.downloadable, + mode='honor', + grade='67', + download_url='http://s3.edx/cert' ) self.assertEqual( @@ -91,19 +95,19 @@ class CourseEndingTest(TestCase): } ) - cert_status = {'status': 'unavailable'} + cert_status = {'status': 'unavailable', 'mode': 'honor', 'uuid': None} self.assertEqual( _cert_info(user, course, cert_status), { 'status': 'processing', 'show_survey_button': False, - 'mode': None, + 'mode': 'honor', 'linked_in_url': None, 'can_unenroll': True, } ) - cert_status = {'status': 'generating', 'grade': '0.67', 'mode': 'honor'} + cert_status = {'status': 'generating', 'grade': '0.67', 'mode': 'honor', 'uuid': None} with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade: patch_persisted_grade.return_value = Mock(percent=1.0) self.assertEqual( @@ -119,7 +123,7 @@ class CourseEndingTest(TestCase): } ) - cert_status = {'status': 'generating', 'grade': '0.67', 'mode': 'honor'} + cert_status = {'status': 'generating', 'grade': '0.67', 'mode': 'honor', 'uuid': None} self.assertEqual( _cert_info(user, course, cert_status), { @@ -133,19 +137,18 @@ class CourseEndingTest(TestCase): } ) - download_url = 'http://s3.edx/cert' cert_status = { 'status': 'downloadable', 'grade': '0.67', - 'download_url': download_url, - 'mode': 'honor' + 'download_url': cert.download_url, + 'mode': 'honor', + 'uuid': 'fakeuuidbutitsfine', } - self.assertEqual( _cert_info(user, course, cert_status), { 'status': 'downloadable', - 'download_url': download_url, + 'download_url': cert.download_url, 'show_survey_button': True, 'survey_url': survey_url, 'grade': '0.67', @@ -157,8 +160,9 @@ class CourseEndingTest(TestCase): cert_status = { 'status': 'notpassing', 'grade': '0.67', - 'download_url': download_url, - 'mode': 'honor' + 'download_url': cert.download_url, + 'mode': 'honor', + 'uuid': 'fakeuuidbutitsfine', } self.assertEqual( _cert_info(user, course, cert_status), @@ -177,7 +181,7 @@ class CourseEndingTest(TestCase): course2 = Mock(end_of_course_survey_url=None, id=CourseLocator(org="a", course="b", run="c")) cert_status = { 'status': 'notpassing', 'grade': '0.67', - 'download_url': download_url, 'mode': 'honor' + 'download_url': cert.download_url, 'mode': 'honor', 'uuid': 'fakeuuidbutitsfine' } self.assertEqual( _cert_info(user, course2, cert_status), @@ -193,7 +197,7 @@ class CourseEndingTest(TestCase): # test when the display is unavailable or notpassing, we get the correct results out course2.certificates_display_behavior = 'early_no_info' - cert_status = {'status': 'unavailable'} + cert_status = {'status': 'unavailable', 'mode': 'honor', 'uuid': None} self.assertEqual( _cert_info(user, course2, cert_status), { @@ -205,8 +209,9 @@ class CourseEndingTest(TestCase): cert_status = { 'status': 'notpassing', 'grade': '0.67', - 'download_url': download_url, - 'mode': 'honor' + 'download_url': cert.download_url, + 'mode': 'honor', + 'uuid': 'fakeuuidbutitsfine' } self.assertEqual( _cert_info(user, course2, cert_status), @@ -234,18 +239,17 @@ class CourseEndingTest(TestCase): from the certs table is used on the learner dashboard. """ expected_grade = max(filter(lambda x: x is not None, [persisted_grade, cert_grade])) - user = Mock(username="fred", id="1") + user = UserFactory.create() survey_url = "http://a_survey.com" - course = Mock( + course = CourseOverviewFactory.create( end_of_course_survey_url=survey_url, certificates_display_behavior='end', - id=CourseLocator(org="x", course="y", run="z"), ) if cert_grade is not None: - cert_status = {'status': 'generating', 'grade': six.text_type(cert_grade), 'mode': 'honor'} + cert_status = {'status': 'generating', 'grade': str(cert_grade), 'mode': 'honor', 'uuid': None} else: - cert_status = {'status': 'generating', 'mode': 'honor'} + cert_status = {'status': 'generating', 'mode': 'honor', 'uuid': None} with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade: patch_persisted_grade.return_value = Mock(percent=persisted_grade) @@ -255,7 +259,7 @@ class CourseEndingTest(TestCase): 'status': 'generating', 'show_survey_button': True, 'survey_url': survey_url, - 'grade': six.text_type(expected_grade), + 'grade': str(expected_grade), 'mode': 'honor', 'linked_in_url': None, 'can_unenroll': False, @@ -268,14 +272,13 @@ class CourseEndingTest(TestCase): when the learner has no persisted grade or grade in the certs table. """ - user = Mock(username="fred", id="1") + user = UserFactory.create() survey_url = "http://a_survey.com" - course = Mock( + course = CourseOverviewFactory.create( end_of_course_survey_url=survey_url, certificates_display_behavior='end', - id=CourseLocator(org="x", course="y", run="z"), ) - cert_status = {'status': 'generating', 'mode': 'honor'} + cert_status = {'status': 'generating', 'mode': 'honor', 'uuid': None} with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade: patch_persisted_grade.return_value = None @@ -422,7 +425,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): self.assertEqual(response.status_code, 200) self.assertNotContains(response, 'Add Certificate to LinkedIn') - response_url = 'http://www.linkedin.com/profile/add?_ed=' + response_url = 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME' self.assertNotContains(response, escape(response_url)) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @@ -432,49 +435,55 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): # should be visible. and it has URL value with valid parameters. self.client.login(username="jack", password="test") - LinkedInAddToProfileConfiguration.objects.create( - company_identifier='0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9', - enabled=True - ) - + linkedin_config = LinkedInAddToProfileConfiguration.objects.create(company_identifier='1337', enabled=True) CourseModeFactory.create( course_id=self.course.id, mode_slug='verified', mode_display_name='verified', expiration_datetime=datetime.now(pytz.UTC) - timedelta(days=1) ) - - self.course.certificate_available_date = datetime.now(pytz.UTC) - timedelta(days=1) CourseEnrollment.enroll(self.user, self.course.id, mode='honor') - + self.course.certificate_available_date = datetime.now(pytz.UTC) - timedelta(days=1) self.course.start = datetime.now(pytz.UTC) - timedelta(days=2) self.course.end = datetime.now(pytz.UTC) - timedelta(days=1) - self.course.display_name = u"Omega" + self.course.display_name = 'Omega' self.course = self.update_course(self.course, self.user.id) - download_url = 'www.edx.org' - GeneratedCertificateFactory.create( + cert = GeneratedCertificateFactory.create( user=self.user, course_id=self.course.id, status=CertificateStatuses.downloadable, mode='honor', grade='67', - download_url=download_url + download_url='https://www.edx.org' ) response = self.client.get(reverse('dashboard')) self.assertEqual(response.status_code, 200) self.assertContains(response, 'Add Certificate to LinkedIn') - expected_url = ( - u'http://www.linkedin.com/profile/add' - u'?_ed=0_mC_o2MizqdtZEmkVXjH4eYwMj4DnkCWrZP_D9&' - u'pfCertificationName={platform}+Honor+Code+Certificate+for+Omega&' - u'pfCertificationUrl=www.edx.org&' - u'source=o' - ).format(platform=quote(settings.PLATFORM_NAME.encode('utf-8'))) - - self.assertContains(response, escape(expected_url)) + # We can switch to this and the commented out assertContains once edx-platform reaches Python 3.8 + # expected_url = ( + # 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&' + # 'name={platform}+Honor+Code+Certificate+for+Omega&certUrl={cert_url}&' + # 'organizationId={company_identifier}' + # ).format( + # platform=quote(settings.PLATFORM_NAME.encode('utf-8')), + # cert_url=quote(cert.download_url, safe=''), + # company_identifier=linkedin_config.company_identifier, + # ) + + # self.assertContains(response, escape(expected_url)) + + # These can be removed (in favor of the above) once we are on Python 3.8. Fails in 3.5 because of dict ordering + self.assertContains(response, escape('https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME')) + self.assertContains(response, escape('&name={platform}+Honor+Code+Certificate+for+Omega'.format( + platform=quote(settings.PLATFORM_NAME.encode('utf-8')) + ))) + self.assertContains(response, escape('&certUrl={cert_url}'.format(cert_url=quote(cert.download_url, safe='')))) + self.assertContains(response, escape('&organizationId={company_identifier}'.format( + company_identifier=linkedin_config.company_identifier + ))) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) @@ -649,7 +658,7 @@ class EnrollmentEventTestMixin(EventTestMixin): self.mock_tracker.emit.assert_called_once_with( 'edx.course.enrollment.mode_changed', { - 'course_id': text_type(course_key), + 'course_id': str(course_key), 'user_id': user.pk, 'mode': mode } @@ -661,7 +670,7 @@ class EnrollmentEventTestMixin(EventTestMixin): self.mock_tracker.emit.assert_called_once_with( 'edx.course.enrollment.activated', { - 'course_id': text_type(course_key), + 'course_id': str(course_key), 'user_id': user.pk, 'mode': CourseMode.DEFAULT_MODE_SLUG } @@ -673,7 +682,7 @@ class EnrollmentEventTestMixin(EventTestMixin): self.mock_tracker.emit.assert_called_once_with( 'edx.course.enrollment.deactivated', { - 'course_id': text_type(course_key), + 'course_id': str(course_key), 'user_id': user.pk, 'mode': CourseMode.DEFAULT_MODE_SLUG } @@ -880,7 +889,7 @@ class ChangeEnrollmentViewTest(ModuleStoreTestCase): """ Enroll a student in a course. """ response = self.client.post( reverse('change_enrollment'), { - 'course_id': text_type(course.id), + 'course_id': course.id, 'enrollment_action': 'enroll' } ) @@ -1020,7 +1029,7 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase): self.create_programs_config() self.client.login(username=self.user.username, password=self.password) - course_run = CourseRunFactory(key=six.text_type(self.course.id)) # pylint: disable=no-member + course_run = CourseRunFactory(key=str(self.course.id)) # pylint: disable=no-member course = CatalogCourseFactory(course_runs=[course_run]) self.programs = [ProgramFactory(courses=[course]) for __ in range(2)] @@ -1094,4 +1103,4 @@ class UserAttributeTests(TestCase): def test_unicode(self): UserAttribute.set_user_attribute(self.user, self.name, self.value) for field in (self.name, self.value, self.user.username): - self.assertIn(field, six.text_type(UserAttribute.objects.get(user=self.user))) + self.assertIn(field, str(UserAttribute.objects.get(user=self.user))) diff --git a/lms/djangoapps/certificates/tests/factories.py b/lms/djangoapps/certificates/tests/factories.py index 61df9af239221fadd588fbc3d057577099ea5345..c1dc63aedd92918ac903d68b44c85d144f2f390a 100644 --- a/lms/djangoapps/certificates/tests/factories.py +++ b/lms/djangoapps/certificates/tests/factories.py @@ -87,5 +87,4 @@ class LinkedInAddToProfileConfigurationFactory(DjangoModelFactory): model = LinkedInAddToProfileConfiguration enabled = True - company_identifier = "0_0dPSPyS070e0HsE9HNz_13_d11_" - trk_partner_name = 'unittest' + company_identifier = "1337" diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index 6857a3b03ed8aead4173333b3e77029f5d05a77d..1ce54771a99a1e93c2acf43746b216c40b14d8b2 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -14,8 +14,7 @@ from django.test.client import Client, RequestFactory from django.test.utils import override_settings from django.urls import reverse from mock import patch -from six.moves import range -from six.moves.urllib.parse import urlencode +from urllib.parse import urlencode from course_modes.models import CourseMode from lms.djangoapps.badges.events.course_complete import get_completion_badge @@ -97,7 +96,7 @@ class CommonCertificatesTestCase(ModuleStoreTestCase): self.user.profile.save() self.client.login(username=self.user.username, password='foo') self.request = RequestFactory().request() - self.linkedin_url = u'http://www.linkedin.com/profile/add?{params}' + self.linkedin_url = 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&{params}' self.cert = GeneratedCertificateFactory.create( user=self.user, @@ -264,14 +263,17 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase) test_url = get_certificate_url(course_id=self.course.id, uuid=self.cert.verify_uuid) response = self.client.get(test_url) self.assertEqual(response.status_code, 200) - params = OrderedDict([ - ('_ed', '0_0dPSPyS070e0HsE9HNz_13_d11_',), - ('pfCertificationName', u'{platform_name} Honor Code Certificate for {course_name}'.format( - platform_name=settings.PLATFORM_NAME, - course_name=self.course.display_name, - ).encode('utf-8'),), - ('pfCertificationUrl', self.request.build_absolute_uri(test_url),), - ]) + params = { + 'name': '{platform_name} Honor Code Certificate for {course_name}'.format( + platform_name=settings.PLATFORM_NAME, course_name=self.course.display_name, + ).encode('utf-8'), + 'certUrl': self.request.build_absolute_uri(test_url), + # default value from the LinkedInAddToProfileConfigurationFactory company_identifier + 'organizationId': 1337, + 'certId': self.cert.verify_uuid, + 'issueYear': self.cert.created_date.year, + 'issueMonth': self.cert.created_date.month, + } self.assertContains( response, js_escaped_string(self.linkedin_url.format(params=urlencode(params))), @@ -280,7 +282,7 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase) @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) @with_site_configuration( configuration={ - 'platform_name': 'My Platform Site', 'LINKEDIN_COMPANY_ID': 'test_linkedin_my_site', + 'platform_name': 'My Platform Site', 'LINKEDIN_COMPANY_ID': 2448, }, ) def test_linkedin_share_url_site(self): @@ -292,13 +294,16 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase) response = self.client.get(test_url, HTTP_HOST='test.localhost') self.assertEqual(response.status_code, 200) # the linkedIn share URL with appropriate parameters should be present - params = OrderedDict([ - ('_ed', 'test_linkedin_my_site',), - ('pfCertificationName', u'My Platform Site Honor Code Certificate for {course_name}'.format( + params = { + 'name': 'My Platform Site Honor Code Certificate for {course_name}'.format( course_name=self.course.display_name, - ).encode('utf-8'),), - ('pfCertificationUrl', 'http://test.localhost' + test_url,), - ]) + ).encode('utf-8'), + 'certUrl': 'http://test.localhost' + test_url, + 'organizationId': 2448, + 'certId': self.cert.verify_uuid, + 'issueYear': self.cert.created_date.year, + 'issueMonth': self.cert.created_date.month, + } self.assertContains( response, js_escaped_string(self.linkedin_url.format(params=urlencode(params))), diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 6b87a3982f624c58347fb03677f949bf30e80705..2957df6096603f889c9105c282f2ad1998da724a 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -291,13 +291,9 @@ def _update_social_context(request, context, course, user, user_certificate, pla # Clicking this button sends the user to LinkedIn where they # can add the certificate information to their profile. linkedin_config = LinkedInAddToProfileConfiguration.current() - linkedin_share_enabled = share_settings.get('CERTIFICATE_LINKEDIN', linkedin_config.enabled) - if linkedin_share_enabled: + if linkedin_config.is_enabled(): context['linked_in_url'] = linkedin_config.add_to_profile_url( - course.id, - course.display_name, - user_certificate.mode, - smart_str(share_url) + course.display_name, user_certificate.mode, smart_str(share_url), certificate=user_certificate ) @@ -348,7 +344,8 @@ def _get_user_certificate(request, user, course_key, course, preview_mode=None): user_certificate = GeneratedCertificate( mode=preview_mode, verify_uuid=six.text_type(uuid4().hex), - modified_date=modified_date + modified_date=modified_date, + created_date=datetime.now().date(), ) elif certificates_viewable_for_course(course): # certificate is being viewed by learner or public diff --git a/openedx/core/djangoapps/courseware_api/serializers.py b/openedx/core/djangoapps/courseware_api/serializers.py index b3396aaff50030fbf2f1c3ffed0c5f72a1c66aff..be2164e5d1c4972f783eba1e813dd9b35d557650 100644 --- a/openedx/core/djangoapps/courseware_api/serializers.py +++ b/openedx/core/djangoapps/courseware_api/serializers.py @@ -95,6 +95,7 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract- course_exit_page_is_active = serializers.BooleanField() certificate_data = CertificateDataSerializer() verify_identity_url = AbsoluteURLField() + linkedin_add_to_profile_url = serializers.URLField() def __init__(self, *args, **kwargs): """ diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py index 908a00086a51525a8933ec90375b975eb90c3191..e84b66a76834c5b4ebff550647d4b43991744806 100644 --- a/openedx/core/djangoapps/courseware_api/tests/test_views.py +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -3,13 +3,19 @@ Tests for courseware API """ import unittest from datetime import datetime +from urllib.parse import urlencode import ddt import mock from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing from django.conf import settings +from django.test.client import RequestFactory from django.urls import reverse +from lms.djangoapps.certificates.api import get_certificate_url +from lms.djangoapps.certificates.tests.factories import ( + GeneratedCertificateFactory, LinkedInAddToProfileConfigurationFactory +) from lms.djangoapps.courseware.access_utils import ACCESS_DENIED, ACCESS_GRANTED from lms.djangoapps.courseware.tabs import ExternalLinkCourseTab from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin @@ -73,6 +79,7 @@ class CourseApiTestViews(BaseCoursewareTests): ExternalLinkCourseTab.load('external_link', name='Hidden', link='http://hidden.com', is_hidden=True) ) cls.store.update_item(cls.course, cls.user.id) + LinkedInAddToProfileConfigurationFactory.create() @ddt.data( (True, None, ACCESS_DENIED), @@ -82,6 +89,7 @@ class CourseApiTestViews(BaseCoursewareTests): (False, None, ACCESS_GRANTED), ) @ddt.unpack + @mock.patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) @mock.patch('openedx.core.djangoapps.courseware_api.views.CoursewareMeta.is_microfrontend_enabled_for_user') def test_course_metadata(self, logged_in, enrollment_mode, enable_anonymous, is_microfrontend_enabled_for_user): is_microfrontend_enabled_for_user.return_value = True @@ -92,6 +100,14 @@ class CourseApiTestViews(BaseCoursewareTests): self.client.logout() if enrollment_mode: CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode) + if enrollment_mode == 'verified': + cert = GeneratedCertificateFactory.create( + user=self.user, + course_id=self.course.id, + status='downloadable', + mode='verified', + ) + response = self.client.get(self.url) assert response.status_code == 200 if enrollment_mode: @@ -114,12 +130,32 @@ class CourseApiTestViews(BaseCoursewareTests): 'The audit track does not include a certificate.') assert response.data['certificate_data']['msg'] == expected_audit_message assert response.data['verify_identity_url'] is None + assert response.data['linkedin_add_to_profile_url'] is None else: - # Not testing certificate data for verified learner here. That is tested elsewhere - assert response.data['certificate_data'] is None + assert response.data['certificate_data']['cert_status'] == 'earned_but_not_available' expected_verify_identity_url = reverse('verify_student_verify_now', args=[self.course.id]) # The response contains an absolute URL so this is only checking the path of the final assert expected_verify_identity_url in response.data['verify_identity_url'] + + request = RequestFactory().request() + cert_url = get_certificate_url(course_id=self.course.id, uuid=cert.verify_uuid) + linkedin_url_params = { + 'name': '{platform_name} Verified Certificate for {course_name}'.format( + platform_name=settings.PLATFORM_NAME, course_name=self.course.display_name, + ), + 'certUrl': request.build_absolute_uri(cert_url), + # default value from the LinkedInAddToProfileConfigurationFactory company_identifier + 'organizationId': 1337, + 'certId': cert.verify_uuid, + 'issueYear': cert.created_date.year, + 'issueMonth': cert.created_date.month, + } + expected_linkedin_url = ( + 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&{params}'.format( + params=urlencode(linkedin_url_params) + ) + ) + assert response.data['linkedin_add_to_profile_url'] == expected_linkedin_url elif enable_anonymous and not logged_in: # multiple checks use this handler check_public_access.assert_called() diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index 09bc763617c450662e176ef615ffc93d402e92f4..3c8f9f4b85a58df7fb598a72534bf41a0c7aa1fc 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -21,6 +21,8 @@ from rest_framework.views import APIView from course_modes.models import CourseMode from lms.djangoapps.edxnotes.helpers import is_feature_enabled +from lms.djangoapps.certificates.api import get_certificate_url +from lms.djangoapps.certificates.models import GeneratedCertificate from lms.djangoapps.course_api.api import course_detail from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.access_response import ( @@ -41,7 +43,7 @@ from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.course_duration_limits.access import generate_course_expired_message from openedx.features.discounts.utils import generate_offer_html -from student.models import CourseEnrollment, CourseEnrollmentCelebration +from student.models import CourseEnrollment, CourseEnrollmentCelebration, LinkedInAddToProfileConfiguration from xmodule.modulestore.django import modulestore from xmodule.modulestore.search import path_to_location @@ -53,15 +55,16 @@ class CoursewareMeta: Encapsulates courseware and enrollment metadata. """ def __init__(self, course_key, request, username=''): + self.request = request self.overview = course_detail( - request, - username or request.user.username, + self.request, + username or self.request.user.username, course_key, ) - self.original_user_is_staff = has_access(request.user, 'staff', self.overview).has_access + self.original_user_is_staff = has_access(self.request.user, 'staff', self.overview).has_access self.course_key = course_key self.course_masquerade, self.effective_user = setup_masquerade( - request, + self.request, course_key, staff_access=self.original_user_is_staff, ) @@ -246,6 +249,32 @@ class CoursewareMeta: else: return IDVerificationService.get_verify_location('verify_student_verify_now', self.course_key) + @property + def linkedin_add_to_profile_url(self): + """ + Returns a URL to add a certificate to a LinkedIn profile (will autofill fields). + + Requires LinkedIn sharing to be enabled, either via a site configuration or a + LinkedInAddToProfileConfiguration object being enabled. + """ + if self.effective_user.is_anonymous: + return + + linkedin_config = LinkedInAddToProfileConfiguration.current() + if linkedin_config.is_enabled(): + try: + user_certificate = GeneratedCertificate.eligible_certificates.get( + user=self.effective_user, course_id=self.course_key + ) + except GeneratedCertificate.DoesNotExist: + return + cert_url = self.request.build_absolute_uri( + get_certificate_url(course_id=self.course_key, uuid=user_certificate.verify_uuid) + ) + return linkedin_config.add_to_profile_url( + self.overview.display_name, user_certificate.mode, cert_url, certificate=user_certificate, + ) + class CoursewareInformation(RetrieveAPIView): """ @@ -296,6 +325,7 @@ class CoursewareInformation(RetrieveAPIView): * certificate_data: data regarding the effective user's certificate for the given course * verify_identity_url: URL for a learner to verify their identity. Only returned for learners enrolled in a verified mode. Will update to reverify URL if necessary. + * linkedin_add_to_profile_url: URL to add the effective user's certificate to a LinkedIn Profile. **Parameters:** diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index 7220771e84ff2d0f780eb1c0b72517f5ccb5bf0c..718d213363805164933e53eb6affa4b6e74cc1ee 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -19,7 +19,6 @@ from django.test.client import Client from django.test.utils import override_settings from django.urls import NoReverseMatch, reverse from mock import patch -from six.moves import range from openedx.core.djangoapps.password_policy.compliance import ( NonCompliantPasswordException,