From faaaeeaab381861c052dcd7a386f70a6dae8f959 Mon Sep 17 00:00:00 2001 From: Haftamu Kebede Date: Mon, 8 Sep 2025 11:29:11 +0300 Subject: [PATCH] feat: Certificate sharing to linkedin (optionally) consider course level organization name 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 b5d46949b506..834c230871da 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 8cd1fcef7aa4..aa46de76c477 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 @@ def add_to_profile_url(self, course_name, cert_mode, cert_url, certificate=None) 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 @@ def _cert_name(self, course_name, cert_mode): 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 a5de21595bb6..87c2d404aeff 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 @@ def test_linked_in_url(self, cert_mode, expected_cert_name): 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 @@ def test_linked_in_url_with_cert_name_override(self, cert_mode, expected_cert_na 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 c2e6d3b2aa9c..e8e8c58f40c2 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -51,8 +51,6 @@ 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 @@ def test_linked_in_add_to_profile_btn_with_certificate(self): 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 @@ def test_linked_in_add_to_profile_btn_with_certificate(self): ).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 3b6cc75e4890..0afa7a687d28 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 ee37835b4841..1dcfc740c84c 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -288,7 +288,7 @@ def linkedin_add_to_profile_url(self): 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