From e46cfa6b320280027a2a1a3878a4e16cf9f70682 Mon Sep 17 00:00:00 2001 From: Haftamu Kebede <54979169+haftamuk@users.noreply.github.com> Date: Tue, 7 Oct 2025 23:02:08 +0300 Subject: [PATCH] feat: Certificate sharing to linkedin (optionally) consider course level organization name (#37331) By adjusting social media sharing settings(specifically linkedin) certificate parameters are autopopulated to LinkedIn API. Additional setting parameters(such as CERTIFICATE_LINKEDIN_DEFAULTS_TO_COURSE_ORGANIZATION_NAME) are introduced to override existing(platform level parameter for organization name) parameters for an operator to configure course level organization name. This will enable learners to share certificate in to LinkedIn with an option for course associated organization to be autopopulated. --- common/djangoapps/student/helpers.py | 2 +- common/djangoapps/student/models/user.py | 61 +++++++++++++---- .../djangoapps/student/tests/test_linkedin.py | 68 +++++++++++++++++-- common/djangoapps/student/tests/tests.py | 5 +- lms/djangoapps/certificates/views/webview.py | 2 +- .../core/djangoapps/courseware_api/views.py | 2 +- 6 files changed, 115 insertions(+), 25 deletions(-) diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index b5d46949b5..834c230871 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -589,7 +589,7 @@ def _cert_info(user, enrollment, cert_status): linkedin_config = LinkedInAddToProfileConfiguration.current() if linkedin_config.is_enabled(): status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( - course_overview.display_name, cert_status.get('mode'), cert_status['download_url'], + course_overview, cert_status.get('mode'), cert_status['download_url'], ) if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}: diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index 8cd1fcef7a..aa46de76c4 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -1375,21 +1375,37 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel): ), ) + @property + def share_settings(self): + """ + Initialize share_settings once for reuse across methods + """ + if self._share_settings is None: + self._share_settings = configuration_helpers.get_value( + 'SOCIAL_SHARING_SETTINGS', + settings.SOCIAL_SHARING_SETTINGS + ) + return self._share_settings + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._share_settings = None + def is_enabled(self, *key_fields): # pylint: disable=arguments-differ """ 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) + return self.share_settings.get('CERTIFICATE_LINKEDIN', enabled) + + def add_to_profile_url(self, course, cert_mode, cert_url, certificate=None): - 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_name (str): The display name of the course. + course (CourseOverview): Course/CourseOverview Object. cert_mode (str): The course mode of the user's certificate (e.g. "verified", "honor", "professional") cert_url (str): The URL for the certificate. @@ -1398,11 +1414,11 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel): If provided, this function will also autofill the certId and issue date for the cert. """ params = { - 'name': self._cert_name(course_name, cert_mode), + 'name': self._cert_name(course.display_name, cert_mode), 'certUrl': cert_url, } - params.update(self._organization_information()) + params.update(self._organization_information(course)) if certificate: params.update({ @@ -1426,28 +1442,45 @@ 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, _('{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) + cert_name = self.share_settings.get( + 'CERTIFICATE_LINKEDIN_MODE_TO_CERT_NAME', {} + ).get(cert_mode, default_cert_name) return cert_name.format( platform_name=configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), course_name=course_name ) - def _organization_information(self): + def _organization_information(self, course=None): """ - Returns organization information for use in the URL parameters for add to profile. + Returns organization information for use in the URL parameters for add to + profile. By default when sharing to LinkedIn, Platform Name and/or Platform + LINKEDIN_COMPANY_ID will be used. If Course specific Organization Name is + prefered when sharing Certificate to linkedIn the flag for that + CERTIFICATE_LINKEDIN_DEFAULTS_TO_COURSE_ORGANIZATION_NAME should be set + to True alongside other LinkedIn settings Returns: - dict: Either the organization ID on LinkedIn or the organization's name + dict: Either the organization ID on LinkedIn, the organization's name or + organization name associated to a specific course 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_course_organization_name = self.share_settings.get( + 'CERTIFICATE_LINKEDIN_DEFAULTS_TO_COURSE_ORGANIZATION_NAME', False + ) + if (prefer_course_organization_name and course): + return {"organizationName": course.display_organization} + + 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 {"organizationId": org_id} return {'organizationName': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)} diff --git a/common/djangoapps/student/tests/test_linkedin.py b/common/djangoapps/student/tests/test_linkedin.py index a5de21595b..87c2d404ae 100644 --- a/common/djangoapps/student/tests/test_linkedin.py +++ b/common/djangoapps/student/tests/test_linkedin.py @@ -1,9 +1,8 @@ """Tests for LinkedIn Add to Profile configuration. """ - +from types import SimpleNamespace from urllib.parse import quote import ddt - from django.conf import settings from django.test import TestCase @@ -17,6 +16,7 @@ class LinkedInAddToProfileUrlTests(TestCase): COURSE_NAME = 'Test Course ☃' CERT_URL = 'http://s3.edx/cert' + COURSE_ORGANIZATION = 'TEST+ORGANIZATION' SITE_CONFIGURATION = { 'SOCIAL_SHARING_SETTINGS': { 'CERTIFICATE_LINKEDIN_MODE_TO_CERT_NAME': { @@ -27,6 +27,17 @@ class LinkedInAddToProfileUrlTests(TestCase): } } } + SITE_CONFIGURATION_COURSE_LEVEL_ORG = { + 'SOCIAL_SHARING_SETTINGS': { + 'CERTIFICATE_LINKEDIN_DEFAULTS_TO_COURSE_ORGANIZATION_NAME': True, + 'CERTIFICATE_LINKEDIN_MODE_TO_CERT_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', 'Honor+Code+Certificate+for+Test+Course+%E2%98%83'), @@ -49,7 +60,13 @@ class LinkedInAddToProfileUrlTests(TestCase): company_identifier=config.company_identifier, ) - actual_url = config.add_to_profile_url(self.COURSE_NAME, cert_mode, self.CERT_URL) + course_mock_object = SimpleNamespace( + display_name=self.COURSE_NAME, display_organization=self.COURSE_ORGANIZATION + ) + + actual_url = config.add_to_profile_url( + course_mock_object, cert_mode, self.CERT_URL + ) self.assertEqual(actual_url, expected_url) @@ -74,8 +91,49 @@ class LinkedInAddToProfileUrlTests(TestCase): 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_NAME, cert_mode, self.CERT_URL) + course_mock_object = SimpleNamespace( + display_name=self.COURSE_NAME, + display_organization=self.COURSE_ORGANIZATION, + ) + actual_url = config.add_to_profile_url( + course_mock_object, cert_mode, self.CERT_URL + ) + self.assertEqual(actual_url, expected_url) + + @ddt.data( + ('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_course_org_name_override( + self, cert_mode, expected_cert_name + ): + config = LinkedInAddToProfileConfigurationFactory() + + expected_url = ( + 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&' + 'name={platform}+{cert_name}&certUrl={cert_url}&' + 'organizationName={course_organization_name}' + ).format( + platform=quote(settings.PLATFORM_NAME.encode('utf-8')), + cert_name=expected_cert_name, + cert_url=quote(self.CERT_URL, safe=''), + course_organization_name=quote(self.COURSE_ORGANIZATION.encode('utf-8')), + ) + + with with_site_configuration_context( + configuration=self.SITE_CONFIGURATION_COURSE_LEVEL_ORG + ): + course_mock_object = SimpleNamespace( + display_name=self.COURSE_NAME, + display_organization=self.COURSE_ORGANIZATION, + ) + actual_url = config.add_to_profile_url( + course_mock_object, cert_mode, self.CERT_URL + ) self.assertEqual(actual_url, expected_url) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index c2e6d3b2aa..e8e8c58f40 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -51,8 +51,6 @@ from openedx.features.course_experience.url_helpers import make_learning_mfe_cou from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order - - log = logging.getLogger(__name__) BETA_TESTER_METHOD = 'common.djangoapps.student.helpers.access.is_beta_tester' @@ -426,6 +424,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): self.course.start = datetime.now(pytz.UTC) - timedelta(days=2) self.course.end = datetime.now(pytz.UTC) - timedelta(days=1) self.course.display_name = 'Omega' + self.course.course_organization = 'Omega Org' self.course = self.update_course(self.course, self.user.id) cert = GeneratedCertificateFactory.create( @@ -449,7 +448,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): ).format( platform=quote(settings.PLATFORM_NAME.encode('utf-8')), cert_url=quote(cert.download_url, safe=''), - company_identifier=linkedin_config.company_identifier + company_identifier=linkedin_config.company_identifier, ) # Single assertion for the expected LinkedIn URL diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 3b6cc75e48..0afa7a687d 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -305,7 +305,7 @@ def _update_social_context(request, context, course, user_certificate, platform_ linkedin_config = LinkedInAddToProfileConfiguration.current() if linkedin_config.is_enabled(): context['linked_in_url'] = linkedin_config.add_to_profile_url( - course.display_name, user_certificate.mode, smart_str(share_url), certificate=user_certificate + course, user_certificate.mode, smart_str(share_url), certificate=user_certificate ) diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index ee37835b48..1dcfc740c8 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -288,7 +288,7 @@ class CoursewareMeta: get_certificate_url(course_id=self.course_key, uuid=user_certificate.verify_uuid) ) return linkedin_config.add_to_profile_url( - self.course_overview.display_name, user_certificate.mode, cert_url, certificate=user_certificate, + self.course_overview, user_certificate.mode, cert_url, certificate=user_certificate, ) @property