AA-385: Add in LinkedIn Add to Profile to courseware meta API

A major update to this function allows it to actually autofill the
certificate information again! I believe LinkedIn changed their API
and we never updated our end. This fixes that!
This commit is contained in:
Dillon Dumesnil
2020-10-09 15:16:56 -04:00
parent 5a66ed1c41
commit 084ab4c10d
12 changed files with 316 additions and 301 deletions

View File

@@ -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 (
@@ -447,19 +446,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),
)
@@ -470,6 +462,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 = {
@@ -496,7 +505,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
@@ -552,15 +561,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'}:

View File

@@ -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
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.
"""
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
)
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

View File

@@ -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')

View File

@@ -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
)
config = LinkedInAddToProfileConfigurationFactory()
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'))
)
# 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_KEY,
self.COURSE_NAME,
cert_mode,
self.CERT_URL
)
actual_url = config.add_to_profile_url(self.COURSE_NAME, cert_mode, self.CERT_URL)
self.assertEqual(actual_url, expected_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
)
config = LinkedInAddToProfileConfigurationFactory()
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'))
)
# 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
)
actual_url = config.add_to_profile_url(self.COURSE_NAME, cert_mode, self.CERT_URL)
self.assertEqual(actual_url, expected_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)
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)
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)

View File

@@ -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')))
# 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))
# 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)))

View File

@@ -87,5 +87,4 @@ class LinkedInAddToProfileConfigurationFactory(DjangoModelFactory):
model = LinkedInAddToProfileConfiguration
enabled = True
company_identifier = "0_0dPSPyS070e0HsE9HNz_13_d11_"
trk_partner_name = 'unittest'
company_identifier = "1337"

View File

@@ -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))),

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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()

View File

@@ -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,
)
@@ -244,6 +247,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):
"""
@@ -294,6 +323,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:**

View File

@@ -18,9 +18,7 @@ from django.http import HttpResponse
from django.test.client import Client
from django.test.utils import override_settings
from django.urls import NoReverseMatch, reverse
from freezegun import freeze_time
from mock import patch
from six.moves import range
from openedx.core.djangoapps.password_policy.compliance import (
NonCompliantPasswordException,