Merge branch 'master' into areeb/discussions-edit-errant-string

This commit is contained in:
Peter Pinch
2025-10-17 12:50:25 -04:00
committed by GitHub
285 changed files with 18081 additions and 17544 deletions

View File

@@ -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'}:

View File

@@ -44,7 +44,7 @@ from eventtracking import tracker
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField, LearningContextKeyField
from pytz import UTC, timezone
from user_util import user_util
from openedx.core.lib import user_util
import openedx.core.djangoapps.django_comment_common.comment_client as cc
from common.djangoapps.util.model_utils import emit_field_changed_events, get_changed_fields_dict
@@ -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)}

View File

@@ -36,6 +36,7 @@ from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.modulestore.tests.django_utils import \
SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
from common.test.utils import assert_dict_contains_subset
class TestUserProfileEvents(UserSettingsEventTestMixin, TestCase):
@@ -271,7 +272,8 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
enrollment = CourseEnrollment.enroll(self.user, self.course.id)
self.assertTrue(self.receiver_called)
self.assertDictContainsSubset(
assert_dict_contains_subset(
self,
{
"signal": COURSE_ENROLLMENT_CREATED,
"sender": None,
@@ -294,7 +296,7 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
creation_date=enrollment.created,
),
},
event_receiver.call_args.kwargs
event_receiver.call_args.kwargs,
)
def test_enrollment_changed_event_emitted(self):
@@ -314,7 +316,8 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
enrollment.update_enrollment(mode="verified")
self.assertTrue(self.receiver_called)
self.assertDictContainsSubset(
assert_dict_contains_subset(
self,
{
"signal": COURSE_ENROLLMENT_CHANGED,
"sender": None,
@@ -337,7 +340,7 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
creation_date=enrollment.created,
),
},
event_receiver.call_args.kwargs
event_receiver.call_args.kwargs,
)
def test_unenrollment_completed_event_emitted(self):
@@ -357,7 +360,8 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
CourseEnrollment.unenroll(self.user, self.course.id)
self.assertTrue(self.receiver_called)
self.assertDictContainsSubset(
assert_dict_contains_subset(
self,
{
"signal": COURSE_UNENROLLMENT_COMPLETED,
"sender": None,
@@ -380,7 +384,7 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
creation_date=enrollment.created,
),
},
event_receiver.call_args.kwargs
event_receiver.call_args.kwargs,
)
@@ -430,7 +434,8 @@ class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin):
role.add_users(self.user)
self.assertTrue(self.receiver_called)
self.assertDictContainsSubset(
assert_dict_contains_subset(
self,
{
"signal": COURSE_ACCESS_ROLE_ADDED,
"sender": None,
@@ -448,7 +453,7 @@ class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin):
role=role._role_name, # pylint: disable=protected-access
),
},
event_receiver.call_args.kwargs
event_receiver.call_args.kwargs,
)
@ddt.data(
@@ -468,7 +473,8 @@ class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin):
role.remove_users(self.user)
self.assertTrue(self.receiver_called)
self.assertDictContainsSubset(
assert_dict_contains_subset(
self,
{
"signal": COURSE_ACCESS_ROLE_REMOVED,
"sender": None,
@@ -486,5 +492,5 @@ class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin):
role=role._role_name, # pylint: disable=protected-access
),
},
event_receiver.call_args.kwargs
event_receiver.call_args.kwargs,
)

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ from openedx.core.djangoapps.user_authn.views.login import login_user
from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerFactory
from .base import IntegrationTestMixin
from common.test.utils import assert_dict_contains_subset
TESTSHIB_ENTITY_ID = "https://idp.testshib.org/idp/shibboleth"
TESTSHIB_METADATA_URL = "https://mock.testshib.org/metadata/testshib-providers.xml"
@@ -402,8 +403,10 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
assert msg.startswith("SAML login %s")
assert action_type == "request"
assert idp_name == self.PROVIDER_IDP_SLUG
self.assertDictContainsSubset(
{"idp": idp_name, "auth_entry": "login", "next": expected_next_url}, request_data
assert_dict_contains_subset(
self,
{"idp": idp_name, "auth_entry": "login", "next": expected_next_url},
request_data,
)
assert next_url == expected_next_url
assert "<samlp:AuthnRequest" in xml
@@ -412,7 +415,7 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
assert msg.startswith("SAML login %s")
assert action_type == "response"
assert idp_name == self.PROVIDER_IDP_SLUG
self.assertDictContainsSubset({"RelayState": idp_name}, response_data)
assert_dict_contains_subset(self, {"RelayState": idp_name}, response_data)
assert "SAMLResponse" in response_data
assert next_url == expected_next_url
assert "<saml2p:Response" in xml

View File

@@ -6,6 +6,7 @@ import ddt
from common.djangoapps.third_party_auth.identityserver3 import IdentityServer3
from common.djangoapps.third_party_auth.tests import testutil
from common.djangoapps.third_party_auth.tests.utils import skip_unless_thirdpartyauth
from common.test.utils import assert_dict_contains_subset
@skip_unless_thirdpartyauth()
@@ -97,7 +98,8 @@ class IdentityServer3Test(testutil.TestCase):
Test user details fields are mapped to default keys
"""
provider_config = self.configure_identityServer3_provider(enabled=True)
self.assertDictContainsSubset(
assert_dict_contains_subset(
self,
{
"username": "Edx",
"email": "edxopenid@example.com",
@@ -105,5 +107,5 @@ class IdentityServer3Test(testutil.TestCase):
"last_name": "Openid",
"fullname": "Edx Openid"
},
provider_config.backend_class().get_user_details(self.response)
provider_config.backend_class().get_user_details(self.response),
)

View File

@@ -9,6 +9,7 @@ from oauthlib.common import Request
from common.djangoapps.third_party_auth.lti import LTI_PARAMS_KEY, LTIAuthBackend
from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin
from common.test.utils import assert_dict_contains_subset
class UnitTestLTI(unittest.TestCase, ThirdPartyAuthTestMixin):
@@ -55,10 +56,14 @@ class UnitTestLTI(unittest.TestCase, ThirdPartyAuthTestMixin):
lti_max_timestamp_age=10
)
assert parameters
self.assertDictContainsSubset({
'custom_extra': 'parameter',
'user_id': '292832126'
}, parameters)
assert_dict_contains_subset(
self,
{
'custom_extra': 'parameter',
'user_id': '292832126'
},
parameters,
)
def test_validate_lti_valid_request_with_get_params(self):
request = Request(
@@ -72,10 +77,14 @@ class UnitTestLTI(unittest.TestCase, ThirdPartyAuthTestMixin):
lti_max_timestamp_age=10
)
assert parameters
self.assertDictContainsSubset({
'custom_extra': 'parameter',
'user_id': '292832126'
}, parameters)
assert_dict_contains_subset(
self,
{
'custom_extra': 'parameter',
'user_id': '292832126'
},
parameters,
)
def test_validate_lti_old_timestamp(self):
request = Request(

View File

@@ -285,7 +285,6 @@ function getBaseConfig(config, useRequireJs) {
'karma-chrome-launcher',
'karma-firefox-launcher',
'karma-spec-reporter',
'karma-selenium-webdriver-launcher',
'karma-webpack',
'karma-sourcemap-loader',
customPlugin
@@ -332,36 +331,15 @@ function getBaseConfig(config, useRequireJs) {
base: 'Firefox',
prefs: {
'app.update.auto': false,
'app.update.enabled': false
'app.update.enabled': false,
'media.autoplay.default': 0, // allow autoplay
'media.autoplay.blocking_policy': 0, // disable autoplay blocking
'media.autoplay.allow-extension-background-pages': true,
'media.autoplay.enabled.user-gestures-needed': false,
}
},
ChromeDocker: {
base: 'SeleniumWebdriver',
browserName: 'chrome',
getDriver: function() {
return new webdriver.Builder()
.forBrowser('chrome')
.usingServer('http://edx.devstack.chrome:4444/wd/hub')
.build();
}
},
FirefoxDocker: {
base: 'SeleniumWebdriver',
browserName: 'firefox',
getDriver: function() {
var options = new firefox.Options(),
profile = new firefox.Profile();
profile.setPreference('focusmanager.testmode', true);
options.setProfile(profile);
return new webdriver.Builder()
.forBrowser('firefox')
.usingServer('http://edx.devstack.firefox:4444/wd/hub')
.setFirefoxOptions(options)
.build();
}
}
},
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: config.singleRun,

View File

@@ -12,6 +12,15 @@ from django.dispatch import Signal
from markupsafe import escape
def assert_dict_contains_subset(test_case, subset, superset):
"""
Assert that `superset` includes all key/value pairs from `subset`.
"""
test_case.assertTrue(
all(item in superset.items() for item in subset.items())
)
@contextmanager
def nostderr():
"""