Merge branch 'master' into dwong/updgrade-django-5-get-storages

This commit is contained in:
Awais Qureshi
2025-06-17 22:42:55 -04:00
committed by GitHub
42 changed files with 857 additions and 177 deletions

View File

@@ -0,0 +1,117 @@
"""
Test the enable/disable discussions for all units API endpoint.
"""
import json
from django.urls import reverse
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient
from common.djangoapps.student.tests.factories import UserFactory
class BulkEnableDisableDiscussionsTestCase(ModuleStoreTestCase):
"""
Test the enable/disable discussions for all units API endpoint.
"""
def setUp(self):
super().setUp()
self.user = UserFactory(is_staff=True, is_superuser=True)
self.user.set_password(self.user_password)
self.user.save()
self.course_key = CourseKey.from_string("course-v1:edx+TestX+2025")
self.url = reverse('bulk_enable_disable_discussions', args=[str(self.course_key)])
self.client = AjaxEnabledTestClient()
self.client.login(username=self.user.username, password=self.user_password)
# Create a test course
self.course = CourseFactory.create(
org=self.course_key.org,
course=self.course_key.course,
run=self.course_key.run,
default_store=ModuleStoreEnum.Type.split,
display_name="EnableDisableDiscussionsTestCase Course",
)
with self.store.bulk_operations(self.course_key):
section = BlockFactory.create(
parent=self.course,
category='chapter',
display_name="Generated Section",
)
sequence = BlockFactory.create(
parent=section,
category='sequential',
display_name="Generated Sequence",
)
unit1 = BlockFactory.create(
parent=sequence,
category='vertical',
display_name="Unit in Section1",
discussion_enabled=True,
)
unit2 = BlockFactory.create(
parent=sequence,
category='vertical',
display_name="Unit in Section2",
discussion_enabled=True,
)
def test_disable_discussions_for_all_units(self):
"""
Test that the API successfully disables discussions for all units.
"""
self.enable_disable_discussions_for_all_units(False)
def test_enable_discussions_for_all_units(self):
"""
Test that the API successfully enables discussions for all units.
"""
self.enable_disable_discussions_for_all_units(True)
def enable_disable_discussions_for_all_units(self, is_enabled):
"""
Test that the API successfully enables/disables discussions for all units.
"""
data = {
"discussion_enabled": is_enabled
}
response = self.client.put(self.url, data=json.dumps(data), content_type='application/json')
self.assertEqual(response.status_code, 200)
response_data = response.json()
print(response_data)
self.assertEqual(response_data['updated_and_republished'], 0 if is_enabled else 2)
# Check that all verticals now have discussion_enabled set to the expected value
with self.store.bulk_operations(self.course_key):
verticals = self.store.get_items(self.course_key, qualifiers={'block_type': 'vertical'})
for vertical in verticals:
self.assertEqual(vertical.discussion_enabled, is_enabled)
def test_permission_denied_for_non_staff(self):
"""
Test that non-staff users are denied access to the API.
"""
# Create a non-staff user
non_staff_user = UserFactory(is_staff=False, is_superuser=False)
non_staff_user.set_password(self.user_password)
non_staff_user.save()
# Create a new client for the non-staff user
non_staff_client = AjaxEnabledTestClient()
non_staff_client.login(username=non_staff_user.username, password=self.user_password)
response = non_staff_client.put(self.url, content_type='application/json')
self.assertEqual(response.status_code, 403)
def test_badrequest_for_empty_request_body(self):
"""
Test that the API returns a 400 for an empty request body.
"""
response = self.client.put(self.url, data=json.dumps({}), content_type='application/json')
self.assertEqual(response.status_code, 400)

View File

@@ -135,7 +135,7 @@ __all__ = ['course_info_handler', 'course_handler', 'course_listing',
'course_notifications_handler',
'textbooks_list_handler', 'textbooks_detail_handler',
'group_configurations_list_handler', 'group_configurations_detail_handler',
'get_course_and_check_access']
'get_course_and_check_access', 'bulk_enable_disable_discussions']
class AccessListFallback(Exception):
@@ -1710,6 +1710,62 @@ def group_configurations_detail_handler(request, course_key_string, group_config
)
@login_required
@expect_json
@ensure_csrf_cookie
@require_http_methods(["PUT"])
def bulk_enable_disable_discussions(request, course_key_string):
"""
API endpoint to enable/disable discussions for all verticals in the course and republish them.
PUT
json: enable/disable discussions for all units and republish
"""
try:
# Validate the course key
course_key = CourseKey.from_string(course_key_string)
except InvalidKeyError:
return JsonResponseBadRequest({"error": "Invalid course key format"})
user = request.user
# check that logged in user has permissions to update this course
if not has_studio_write_access(user, course_key):
raise PermissionDenied()
if 'application/json' not in request.META.get('HTTP_ACCEPT', 'application/json'):
return JsonResponseBadRequest({"error": "Only supports json requests"})
if 'discussion_enabled' not in request.json:
return JsonResponseBadRequest({"error": "Missing 'discussion_enabled' field in request body"})
discussion_enabled = request.json['discussion_enabled']
log.info(
"User %s is attempting to %s discussions for all verticals in course %s",
user.username,
"enable" if discussion_enabled else "disable",
course_key
)
if request.method == 'PUT':
try:
store = modulestore()
changed = 0
with store.bulk_operations(course_key):
verticals = store.get_items(course_key, qualifiers={'block_type': 'vertical'})
for vertical in verticals:
if vertical.discussion_enabled != discussion_enabled:
vertical.discussion_enabled = discussion_enabled
store.update_item(vertical, user.id)
if store.has_published_version(vertical):
store.publish(vertical.location, user.id)
changed += 1
return JsonResponse({"updated_and_republished": changed})
except Exception as e: # lint-amnesty, pylint: disable=broad-except
log.exception("Exception occurred while enabling/disabling discussion: %s", str(e))
return JsonResponseBadRequest({"error": str(e)})
def are_content_experiments_enabled(course):
"""
Returns True if content experiments have been enabled for the course.

View File

@@ -211,12 +211,6 @@ upstream_info = UpstreamLink.try_get_for_block(xblock, log_error=False)
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li>
% endif
% elif not show_inline:
<li class="action-item action-edit action-edit-view-only">
<a href="#" class="edit-button action-button">
<span class="action-button-text">${_("Details")}</span>
</a>
</li>
% endif
% endif
</ul>

View File

@@ -201,6 +201,9 @@ urlpatterns = oauth2_urlpatterns + [
path('accessibility', contentstore_views.accessibility, name='accessibility'),
re_path(fr'api/youtube/courses/{COURSELIKE_KEY_PATTERN}/edx-video-ids$',
contentstore_views.get_course_youtube_edx_videos_ids, name='youtube_edx_video_ids'),
re_path(fr'^api/courses/{settings.COURSE_KEY_PATTERN}/bulk_enable_disable_discussions$',
contentstore_views.bulk_enable_disable_discussions,
name='bulk_enable_disable_discussions'),
]
if not settings.DISABLE_DEPRECATED_SIGNIN_URL:

View File

@@ -696,10 +696,6 @@ def user_profile_pre_save_callback(sender, **kwargs):
"""
user_profile = kwargs['instance']
# Remove profile images for users who require parental consent
if user_profile.requires_parental_consent() and user_profile.has_profile_image:
user_profile.profile_image_uploaded_at = None
# Cache "old" field values on the model instance so that they can be
# retrieved in the post_save callback when we emit an event with new and
# old field values.

View File

@@ -65,23 +65,3 @@ class ProfileParentalControlsTest(TestCase):
self.set_year_of_birth(current_year - 14)
assert not self.profile.requires_parental_consent()
assert not self.profile.requires_parental_consent(year=current_year)
def test_profile_image(self):
"""Verify that a profile's image obeys parental controls."""
# Verify that an image cannot be set for a user with no year of birth set
self.profile.profile_image_uploaded_at = now()
self.profile.save()
assert not self.profile.has_profile_image
# Verify that an image can be set for an adult user
current_year = now().year
self.set_year_of_birth(current_year - 20)
self.profile.profile_image_uploaded_at = now()
self.profile.save()
assert self.profile.has_profile_image
# verify that a user's profile image is removed when they switch to requiring parental controls
self.set_year_of_birth(current_year - 10)
self.profile.save()
assert not self.profile.has_profile_image

View File

@@ -74,7 +74,7 @@ of Content Groups to Cohorts.
While this might sound a little cumbersome, it actually allows for a cleaner
separation of concerns. Content Groups describe what the content is: restricted
copyright, advanced material, labratory exercises, etc. Cohorts describe who is
copyright, advanced material, laboratory exercises, etc. Cohorts describe who is
consuming that material: on campus students, alumni, the general MOOC audience,
etc. The Content Group is an Authoring decision based on the properties of the
content itself. The Cohort mapping is a policy decision about the Learning

View File

@@ -28,6 +28,7 @@ from xmodule.capa.tests.response_xml_factory import (
OptionResponseXMLFactory,
SchematicResponseXMLFactory
)
from xmodule.capa.tests.test_util import use_unsafe_codejail
from xmodule.capa.xqueue_interface import XQueueInterface
from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule
@@ -810,6 +811,7 @@ class ProblemWithUploadedFilesTest(TestSubmittingProblems):
self.assertEqual(list(kwargs['files'].keys()), filenames.split())
@use_unsafe_codejail()
class TestPythonGradedResponse(TestSubmittingProblems):
"""
Check that we can submit a schematic and custom response, and it answers properly.

View File

@@ -2742,22 +2742,35 @@ class ExportOra2DataView(DeveloperErrorViewMixin, APIView):
return JsonResponse({"error": str(err)}, status=400)
@transaction.non_atomic_requests
@require_POST
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_course_permission(permissions.CAN_RESEARCH)
@common_exceptions_400
def export_ora2_summary(request, course_id):
@method_decorator(transaction.non_atomic_requests, name='dispatch')
class ExportOra2SummaryView(DeveloperErrorViewMixin, APIView):
"""
Pushes a Celery task which will aggregate a summary students' progress in ora2 tasks for a course into a .csv
Pushes a Celery task which will aggregate a summary of students' progress in ora2 tasks for a course into a .csv
"""
course_key = CourseKey.from_string(course_id)
report_type = _('ORA summary')
task_api.submit_export_ora2_summary(request, course_key)
success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type)
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_name = permissions.CAN_RESEARCH
return JsonResponse({"status": success_status})
@method_decorator(ensure_csrf_cookie)
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True))
def post(self, request, course_id):
"""
Initiates a Celery task to generate an ORA summary report for the specified course.
Args:
request: The HTTP request object
course_id: The string representation of the course key
Returns:
Response: A JSON response with a status message indicating the report generation has started
"""
course_key = CourseKey.from_string(course_id)
report_type = _('ORA summary')
try:
task_api.submit_export_ora2_summary(request, course_key)
success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type)
return Response({"status": success_status})
except (AlreadyRunningError, QueueConnectionError, AttributeError) as err:
return JsonResponse({"error": str(err)}, status=400)
@transaction.non_atomic_requests

View File

@@ -67,7 +67,7 @@ urlpatterns = [
# Reports..
path('get_course_survey_results', api.GetCourseSurveyResults.as_view(), name='get_course_survey_results'),
path('export_ora2_data', api.ExportOra2DataView.as_view(), name='export_ora2_data'),
path('export_ora2_summary', api.export_ora2_summary, name='export_ora2_summary'),
path('export_ora2_summary', api.ExportOra2SummaryView.as_view(), name='export_ora2_summary'),
path('export_ora2_submission_files', api.export_ora2_submission_files,
name='export_ora2_submission_files'),

View File

@@ -22,6 +22,7 @@ from django.urls import reverse
from xmodule.capa.responsetypes import StudentInputError
from xmodule.capa.tests.response_xml_factory import CodeResponseXMLFactory, CustomResponseXMLFactory
from xmodule.capa.tests.test_util import use_unsafe_codejail
from lms.djangoapps.courseware.model_data import StudentModule
from lms.djangoapps.grades.api import CourseGradeFactory
from lms.djangoapps.instructor_task.api import (
@@ -71,6 +72,7 @@ class TestIntegrationTask(InstructorTaskModuleTestCase):
@ddt.ddt
@override_settings(RATELIMIT_ENABLE=False)
@use_unsafe_codejail()
class TestRescoringTask(TestIntegrationTask):
"""
Integration-style tests for rescoring problems in a background task.

View File

@@ -188,7 +188,7 @@ class Command(BaseCommand):
return True
site = Site.objects.get_current()
account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/')
account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/')
message_context = get_base_template_context(site)
message_context.update({
'platform_name': settings.PLATFORM_NAME,

View File

@@ -251,7 +251,7 @@ class IDVerificationService:
Returns a string:
Returns URL for IDV on Account Microfrontend
"""
account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/')
account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/')
location = f'{account_base_url}/id-verification'
if course_id:
location += f'?course_id={quote(str(course_id))}'

View File

@@ -1128,7 +1128,7 @@ def results_callback(request): # lint-amnesty, pylint: disable=too-many-stateme
log.info("[COSMO-184] Denied verification for receipt_id={receipt_id}.".format(receipt_id=receipt_id))
attempt.deny(json.dumps(reason), error_code=error_code)
account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/')
account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/')
reverify_url = f'{account_base_url}/id-verification'
verification_status_email_vars['reasons'] = reason
verification_status_email_vars['reverify_url'] = reverify_url

View File

@@ -5411,6 +5411,10 @@ NOTIFICATION_TYPE_ICONS = {}
DEFAULT_NOTIFICATION_ICON_URL = ""
NOTIFICATION_DIGEST_LOGO = DEFAULT_EMAIL_LOGO_URL
############## SELF PACED EMAIL ##############
SELF_PACED_BANNER_URL = ""
SELF_PACED_CLOUD_URL = ""
############## NUDGE EMAILS ###############
# .. setting_name: DISABLED_ORGS_FOR_PROGRAM_NUDGE
# .. setting_default: []

View File

@@ -651,28 +651,40 @@ TOKEN_SIGNING = {
'JWT_ISSUER': 'token-test-issuer',
'JWT_SIGNING_ALGORITHM': 'RS512',
'JWT_SUPPORTED_VERSION': '1.2.0',
'JWT_PRIVATE_SIGNING_JWK': '''{
"e": "AQAB",
"d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ",
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ",
"q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE",
"p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0",
"kid": "token-test-sign", "kty": "RSA"
}''',
'JWT_PUBLIC_SIGNING_JWK_SET': '''{
"keys": [
'JWT_PRIVATE_SIGNING_JWK': """
{
"kid": "token-test-sign",
"kty": "RSA",
"key_ops": [
"sign"
],
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ",
"e": "AQAB",
"d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ",
"p": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE",
"q": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0",
"dp": "Azh08H8r2_sJuBXAzx_mQ6iZnAZQ619PnJFOXjTqnMgcaK8iSHLL2CgDIUQwteUcBphgP0uBrfWIBs5jmM8rUtVz4CcrPb5jdjhHjuu4NxmnFbPlhNoOp8OBUjPP3S-h-fPoaFjxDrUqz_zCdPVzp4S6UTkf6Hu-SiI9CFVFZ8E",
"dq": "WQ44_KTIbIej9qnYUPMA1DoaAF8ImVDIdiOp9c79dC7FvCpN3w-lnuugrYDM1j9Tk5bRrY7-JuE6OaKQgOtajoS1BIxjYHj5xAVPD15CVevOihqeq5Zx0ZAAYmmCKRrfUe0iLx2QnIcoKH1-Azs23OXeeo6nysznZjvv9NVJv60",
"qi": "KSWGH607H1kNG2okjYdmVdNgLxTUB-Wye9a9FNFE49UmQIOJeZYXtDzcjk8IiK3g-EU3CqBeDKVUgHvHFu4_Wj3IrIhKYizS4BeFmOcPDvylDQCmJcC9tXLQgHkxM_MEJ7iLn9FOLRshh7GPgZphXxMhezM26Cz-8r3_mACHu84"
}
""", # noqa: E501,
'JWT_PUBLIC_SIGNING_JWK_SET': """
{
"keys": [
{
"kid":"token-test-wrong-key",
"e": "AQAB",
"kty": "RSA",
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dffgRQLD1qf5D6sprmYfWVokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"
"kid": "token-test-sign",
"kty": "RSA",
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ",
"e": "AQAB"
},
{
"kid":"token-test-sign",
"e": "AQAB",
"kty": "RSA",
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"
"kid": "token-test-wrong-key",
"kty": "RSA",
"n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ",
"e": "AQAB"
}
]
}''',
]
}
""", # noqa: E501
}

View File

@@ -9,6 +9,7 @@ from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartiti
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme
from common.djangoapps.student.models import CourseEnrollment
from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE
INCLUDE_SCHEMES = [CohortPartitionScheme, RandomUserPartitionScheme, ]
SCHEME_SUPPORTS_ASSIGNMENT = [RandomUserPartitionScheme, ]
@@ -63,6 +64,6 @@ class LmsSearchFilterGenerator(SearchFilterGenerator):
if not getattr(settings, "SEARCH_SKIP_INVITATION_ONLY_FILTERING", True):
exclude_dictionary['invitation_only'] = True
if not getattr(settings, "SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING", True):
exclude_dictionary['catalog_visibility'] = 'none'
exclude_dictionary['catalog_visibility'] = [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE]
return exclude_dictionary

View File

@@ -6,6 +6,7 @@ from unittest.mock import Mock, patch
from lms.lib.courseware_search.lms_filter_generator import LmsSearchFilterGenerator
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
@@ -139,3 +140,9 @@ class LmsSearchFilterGeneratorTestCase(ModuleStoreTestCase):
assert 'org' not in exclude_dictionary
assert 'org' in field_dictionary
assert ['TestSite3'] == field_dictionary['org']
@patch('django.conf.settings.SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING', False)
def test_excludes_catalog_visibility(self):
_, _, exclude_dictionary = LmsSearchFilterGenerator.generate_field_filters(user=self.user)
assert 'catalog_visibility' in exclude_dictionary
assert exclude_dictionary['catalog_visibility'] == [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE]

View File

@@ -97,7 +97,7 @@ def create_email_template_context(username):
'channel': 'email',
'value': False
}
account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/')
account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/')
return {
"platform_name": settings.PLATFORM_NAME,
"mailing_address": settings.CONTACT_MAILING_ADDRESS,

View File

@@ -548,6 +548,8 @@ class CourseNextSectionUpdate(PrefixedDebugLoggerMixin, RecipientResolver):
'course_id': str(course.id),
'course_ids': [str(course.id)],
'unsubscribe_url': unsubscribe_url,
'self_paced_banner_url': settings.SELF_PACED_BANNER_URL,
'self_paced_cloud_url': settings.SELF_PACED_CLOUD_URL,
})
template_context.update(_get_upsell_information_for_schedule(user, schedule))

View File

@@ -0,0 +1,91 @@
{% load django_markup %}
{% load i18n %}
<!-- These tags come from the ace_common djangoapp in edx ace -->
{% load ace %}
<!-- These tags come from the edx_ace app within the edx_ace repository -->
{% load acetags %}
{% get_current_language as LANGUAGE_CODE %}
{% get_current_language_bidi as LANGUAGE_BIDI %}
{# This is preview text that is visible in the inbox view of many email clients but not visible in the actual #}
{# email itself. #}
<div lang="{{ LANGUAGE_CODE|default:"en" }}" style="
display:none;
font-size:1px;
line-height:1px;
max-height:0px;
max-width:0px;
opacity:0;
overflow:hidden;
visibility:hidden;
">
{% block preview_text %}{% endblock %}
</div>
{% for image_src in channel.tracker_image_sources %}
<img src="{image_src}" alt="" role="presentation" aria-hidden="true" />
{% endfor %}
{% google_analytics_tracking_pixel %}
<div bgcolor="#F2F0EF" lang="{{ LANGUAGE_CODE|default:"en" }}" dir="{{ LANGUAGE_BIDI|yesno:"rtl,ltr" }}" style="
margin: 0;
padding: 0;
min-width: 100%;
">
<!-- Hack for outlook 2010, which wants to render everything in Times New Roman -->
<!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
<!--[if (gte mso 9)|(IE)]>
<table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<![endif]-->
<!-- CONTENT -->
<table class="content" role="presentation" align="center" cellpadding="0" cellspacing="0" border="0" bgcolor="#fbfaf9" width="100%"
{% block table_style %}
style="
font-family: Arial, sans-serif;
font-size: 1em;
line-height: 1.5;
max-width: 600px;
"
{% endblock %}
>
<tr>
<!-- HEADER -->
<td class="header" style="background-color: #F2F0EF;">
{% block header %}{% endblock %}
</td>
</tr>
<tr>
<!-- MAIN -->
<td class="main" bgcolor="#ffffff">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<!-- FOOTER -->
<td class="footer" style="padding: 30px; background-color: #F2F0EF;">
{% block footer %}{% endblock %}
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</div>

View File

@@ -1,6 +1,8 @@
{% extends 'ace_common/edx_ace/common/base_body.html' %}
{% extends 'schedules/edx_ace/courseupdate/email/base_body.html' %}
{% load i18n %}
{% load django_markup %}
{% load static %}
{% block preview_text %}
{% filter force_escape %}
@@ -11,38 +13,123 @@
{% endblock %}
{% block content %}
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
<table width="100%" style="margin-bottom: 30px;" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
<style>
.course-text{
span{
padding-left: 12px;
padding-right: 12px;
padding-top: 10px;
padding-bottom: 10px;
}
}
</style>
{% if route_enabled %}
<tr>
<td>
<p>
{% blocktrans trimmed asvar tmsg %}
We hope you're enjoying {start_strong}{course_name}{end_strong}!
We want to let you know what you can look forward to in week {week_num}:
{% endblocktrans %}
{% interpolate_html tmsg start_strong='<strong>'|safe end_strong='</strong>'|safe course_name=course_name|force_escape|safe week_num=week_num|force_escape|safe %}
<ul>
{% for highlight in week_highlights %}
<li>{{ highlight }}</li>
{% endfor %}
</ul>
</p>
<p>
{% filter force_escape %}
{% blocktrans trimmed %}
With self-paced courses, you learn on your own schedule.
We encourage you to spend time with the course each week.
Your focused attention will pay off in the end!
{% endblocktrans %}
{% blocktrans %}This is a routed Account Activation email for {{ routed_profile_name }} ({{ routed_user_email }}): {{ routed_profile_name }}{% endblocktrans %}
{% endfilter %}
<br />
</p>
{% filter force_escape %}
{% blocktrans asvar course_cta_text %}Resume your course now{% endblocktrans %}
{% endfilter %}
{% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text%}
{% include "ace_common/edx_ace/common/upsell_cta.html"%}
</td>
</tr>
{% endif %}
<tr>
<td>
<img
src="{{ self_paced_banner_url }}"
style="margin-bottom: 16px; width: 600px;"
width= "600"
height="265"
alt="{% trans 'Welcome to edX. Its time for your next career move' as tmsg %}{{ tmsg | force_escape }}"
/>
</td>
</tr>
<tr>
<td>
<p style="margin-top: 16px; color: #1F453D; font-family: Arial; font-size: 30px; font-weight: 700; line-height: 36px; margin-left: 30px;">
{% trans "We hope youre enjoying Introduction to Data Science with Python!" as tmsg %}{{ tmsg | force_escape }}
</p>
<p style="margin-top: 16px; color: #000000; font-family: Arial; font-size: 16px; font-weight: 400; line-height: 24px; margin-left: 30px;">
{% trans "We want to let you know what you can look forward to in week two: " as tmsg %}{{ tmsg | force_escape }}
</p>
<ul style="margin-bottom: 16px; list-style: disc;">
<li>
<p>
{% trans "Learn kNN regression" as tmsg %}{{ tmsg | force_escape }}
</p>
</li>
<li>
<p>
{% trans "Learn linear regression" as tmsg %}{{ tmsg | force_escape }}
</p>
</li>
<li>
<p>
{% trans "Find out how to choose which model you want" as tmsg %}{{ tmsg | force_escape }}
</p>
</li>
</ul>
</td>
</tr>
<tr style="height: 32px;">
<td style="padding-top: 1rem;" class="course-text">
{% filter force_escape %}
{% blocktrans asvar course_cta_text %}Resume your course {% endblocktrans %}
{% endfilter %}
{% include "schedules/edx_ace/courseupdate/email/return_to_course_cta.html" with course_cta_text=course_cta_text%}
</td>
</tr>
<tr>
<td style="height: 32px; line-height: 32px; font-size: 1px;">
&nbsp;
</td>
</tr>
<tr style="background-color: #F3F1ED;">
<td>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 540px; border-radius: 8px;" bgcolor="#F3F1ED" class="goals-engage-table" align="left">
<!-- Row 1: Image and title -->
<!--[if mso]>
<tr>
<td style="width: 400px; color: #1F453D; font-size: 16px; font-family: Arial, Helvetica, sans-serif; font-weight: 400; line-height: 24px; width: 100%;">
<p style="padding: 20px 30px; margin-left: 30px">
<strong>
{% trans "Your focused attention will pay off in the end!" as tmsg %}{{ tmsg | force_escape }}
</strong>
{% trans "With self-paced courses, you learn on your own schedule. Its a good idea to spend time with the course each week and check in with your goals often." as tmsg %}{{ tmsg | force_escape }}
</p>
</td>
<td width="120" style="width: 120px; padding: 0; margin: 0; vertical-align: top;">
<img src="{{ self_paced_cloud_url }}"
alt="Message Icon"
width="120"
height="158"
style="display: block; border: 0; margin: 0; padding: 0; width: 120px; height: 158px; max-width: 120px !important; max-height: 158px !important;" />
</td>
</tr>
<![endif]-->
<!--[if !mso]><!-->
<tr>
<td style="width: 400px; color: #1F453D; font-size: 16px; font-family: Arial, sans-serif; font-weight: 400; line-height: 24px;">
<p style="padding: 20px 30px; margin: 0px;">
<strong> {% trans "Your focused attention will pay off in the end! " as tmsg %}{{ tmsg | force_escape }} </strong>
{% trans "With self-paced courses, you learn on your own schedule. Its a good idea to spend time with the course each week and check in with your goals often." as tmsg %}{{ tmsg | force_escape }}
</p>
</td>
<td style="padding: 0; margin: 0;" align="left" valign="top">
<img src="{{ self_paced_cloud_url }}" alt="Message Icon" style="display: block; border: 0; margin: 0; padding: 0; width: 120px; height: 158px; margin-top: -40px;">
</td>
</tr>
<!--<![endif]-->
</table>
</td>
</tr>
</table>
{% endblock %}
{% block footer%}
{%include 'schedules/edx_ace/courseupdate/email/footer.html'%}
{% endblock%}

View File

@@ -0,0 +1,195 @@
{% load django_markup %}
{% load i18n %}
{% load ace %}
{% load acetags %}
{% load static %}
<table role="presentation" width="100%" align="left" border="0" cellpadding="0" cellspacing="0">
<style>
.hyperlink {
color: #00688d;
text-decoration: none;
background-color: transparent;
}
.hyperlink:hover {
color: #004972 !important;
text-decoration: underline !important;
}
</style>
{% if confirm_activation_link %}
{% endif %}
<tr>
<td>
<!-- SOCIAL -->
<table role="presentation" align="{{ LANGUAGE_BIDI|yesno:"right,left" }}" border="0" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" valign="top">
<table cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" valign="top" style="padding: 0px 15px;">
<table cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" valign="top">
<table cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" valign="top">
<!-- logo -->
<table align="left" cellpadding="0" cellspacing="0" class="width_100percent">
<tbody>
<tr>
<td align="center" valign="top">
<a href="{% with_link_tracking homepage_url %}">
<img src="{{ logo_url }}" width="92" height="65" alt="{% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %}"/></a>
</td>
</tr>
</tbody>
</table>
<table align="right" cellpadding="0" cellspacing="0" class="width_100percent">
<tbody>
<tr>
<td align="center" class="paddingtop_20 height_0" height="66" valign="middle">
<table cellpadding="0" cellspacing="0">
<tbody>
<tr>
{% if social_media_urls.facebook %}
<td height="32" width="35">
<a href="{{ social_media_urls.facebook|safe }}">
<img src="http://email-media.s3.amazonaws.com/edX/2021/social_1_fb.png"
width="25" height="25" alt="{% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %}"/>
</a>
</td>
{% endif %}
{% if social_media_urls.instagram %}
<td height="32" width="35">
<a href="{{ social_media_urls.instagram|safe }}">
<img src="http://email-media.s3.amazonaws.com/edX/2021/social_4_insta.png"
width="25" height="25" alt="{% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %}"/>
</a>
</td>
{% endif %}
{% if social_media_urls.linkedin %}
<td height="32" width="35">
<a href="{{ social_media_urls.linkedin|safe }}">
<img src="http://email-media.s3.amazonaws.com/edX/2021/social_3_linkedin.png"
width="25" height="25" alt="{% filter force_escape %}{% blocktrans %}{{ platform_name }} on LinkedIn{% endblocktrans %}{% endfilter %}"/>
</a>
</td>
{% endif %}
{% if social_media_urls.twitter %}
<td height="32" width="35">
<a href="{{ social_media_urls.twitter|safe }}">
<img src="http://email-media.s3.amazonaws.com/edX/2021/social_2_twitter.png"
width="25" height="25" alt="{% filter force_escape %}{% blocktrans %}{{ platform_name }} on Twitter{% endblocktrans %}{% endfilter %}"/>
</a>
</td>
{% endif %}
{% if social_media_urls.reddit %}
<td height="32" width="35">
<a href="{{ social_media_urls.reddit|safe }}">
<img src="http://email-media.s3.amazonaws.com/edX/2021/social_5_reddit.png"
width="25" height="25" alt="{% filter force_escape %}{% blocktrans %}{{ platform_name }} on Reddit{% endblocktrans %}{% endfilter %}"/>
</a>
</td>
{% endif %}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td height="20" style="line-height:1px;font-size:1px;"></td>
</tr>
<!-- APP BUTTONS -->
<tr>
<td align="left" valign="top" style="padding: 0px 15px;">
<table cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" valign="top">
<table align="left" cellpadding="0" cellspacing="0" class="width_100percent">
<tbody>
<tr>
<td align="center" valign="top">
<table cellpadding="0" cellspacing="0">
<tbody>
<tr>
{% if mobile_store_urls.apple %}
<td align="left" valign="top">
<a href="{{ mobile_store_urls.apple|safe }}" style="text-decoration: none">
<img src="http://email-media.s3.amazonaws.com/edX/2021/store_apple_229x78.jpg"
alt="{% trans "Download the iOS app on the Apple Store" as tmsg %}{{ tmsg | force_escape }}"
border="0" height="32" style="display:block;" width="95.135" /></a>
</a>
</td>
{% endif %}
<td width="20"></td>
{% if mobile_store_urls.google %}
<td align="left" valign="top">
<a href="{{ mobile_store_urls.google|safe }}" style="text-decoration: none">
<img src="http://email-media.s3.amazonaws.com/edX/2021/store_google_253x78.jpg"
alt="{% trans "Download the Android app on the Google Play Store" as tmsg %}{{ tmsg | force_escape }}"
border="0" height="32" style="display:block;" width="108.108" /></a>
</a>
</td>
{% endif %}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td height="24" style="line-height:1px;font-size:1px;"></td>
</tr>
<tr>
<!-- COPYRIGHT -->
<td align="left" class="fallback_font" style="font-family:Arial,sans-serif;font-size:14px;line-height:21px;color:#000000;font-style: normal;font-weight: 400;" valign="top">
{% if disclaimer %}
{{ disclaimer }}<br/>
{% endif %}
{% trans "edX is the trusted platform for education and learning" as tmsg %}{{ tmsg | force_escape }}.<br/>
<br/>
&copy; {% now "Y" %} {{ platform_name }} LLC. {% trans "All rights reserved" as tmsg %}{{ tmsg | force_escape }}.<br/>
<br/>
{% if unsubscribe_link %}
<a href="{% with_link_tracking unsubscribe_link %}" style="color: #000000;">
{%if unsubscribe_text%} {{unsubscribe_text}} {%else%} {% trans "Unsubscribe from these emails." as tmsg %}{{ tmsg | force_escape }} {%endif%}
</a><br/>
<br/>
{% endif %}
{{ contact_mailing_address }}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>

View File

@@ -1 +1,40 @@
{% extends 'ace_common/edx_ace/common/base_head.html' %}
{% load django_markup %}
{% load i18n %}
{% load ace %}
{% load acetags %}
{% load static %}
<table width="600" border="0" cellspacing="0" cellpadding="0" align="center" bgcolor="#F2F0EF">
<tr>
<td valign="top" align="center">
<table width="600" cellpadding="0" cellspacing="0" align="center" class="width_100percent">
<tr>
<td valign="top" align="center" style="max-width:600px;" class="min_width">
<table width="100%" cellpadding="0" cellspacing="0" bgcolor="#F2F0EF">
<tr><td height="20" style="line-height:1px;">&nbsp;</td></tr>
<tr>
<td align="center" valign="top" style="padding:0 60px;" class="padding_none">
<table cellpadding="0" cellspacing="0" align="center" class="width_100percent">
<tr>
<td align="center" valign="top">
<table cellpadding="0" cellspacing="0">
<tr>
<td align="center" valign="top">
<a href="{% with_link_tracking homepage_url %}">
<img src="{{ logo_url }}" width="92" height="65" alt="{% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %}"/></a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr><td height="20" style="line-height:1px;font-size:1px;">&nbsp;</td></tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@@ -0,0 +1,35 @@
{% load i18n %}
{% load ace %}
<p>
{# email client support for style sheets is pretty spotty, so we have to inline all of these styles #}
<a
{% if reset_url %}
href={{reset_url}}
{% elif course_cta_url %}
href="{% with_link_tracking course_cta_url %}"
{% else %}
{%if course_ids|length > 1 %}
href="{% with_link_tracking dashboard_url %}"
{% else %}
href="{% with_link_tracking course_url %}"
{% endif %}
{% endif %}
style="
text-decoration: none;
color: white;
background-color: #ED5C13;
text-align: center;
vertical-align: middle;
user-select: none;
font-weight: 500;
font-size: 12px;
text-decoration-style: solid;
display: inline-flex;
flex-direction: row;
border-radius: 30.22px;
">
{# old email clients require the use of the font tag :( #}
<span color="#ffffff"><b>{{ course_cta_text }}</b></span>
</a>
</p>

View File

@@ -271,6 +271,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor
@override_settings(CONTACT_MAILING_ADDRESS='123 Sesame Street')
@override_settings(LOGO_URL_PNG='https://www.logo.png')
@override_settings(SELF_PACED_BANNER_URL='')
@override_settings(SELF_PACED_CLOUD_URL='')
def test_schedule_context(self):
resolver = self.create_resolver()
# using this to make sure the select_related stays intact
@@ -316,6 +318,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor
'twitter': twitter_url},
'template_revision': 'release',
'unsubscribe_url': None,
'self_paced_banner_url': '',
'self_paced_cloud_url': '',
'week_highlights': ['good stuff 2'],
'week_num': 2,
}

View File

@@ -403,17 +403,18 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
assert data['social_links'] is not None
assert data['time_zone'] is None
def _verify_private_account_response(self, response, requires_parental_consent=False):
def _verify_private_account_response(self, response, requires_parental_consent=False, has_profile_image=True):
"""
Verify that only the public fields are returned if a user does not want to share account fields
"""
data = response.data
assert 3 == len(data)
assert PRIVATE_VISIBILITY == data['account_privacy']
self._verify_profile_image_data(data, not requires_parental_consent)
self._verify_profile_image_data(data, has_profile_image)
assert self.user.username == data['username']
def _verify_full_account_response(self, response, requires_parental_consent=False, year_of_birth=2000):
def _verify_full_account_response(self, response, requires_parental_consent=False,
has_profile_image=True, year_of_birth=2000):
"""
Verify that all account fields are returned (even those that are not shareable).
"""
@@ -426,7 +427,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
UserPreference.get_value(self.user, 'account_privacy')
)
assert expected_account_privacy == data['account_privacy']
self._verify_profile_image_data(data, not requires_parental_consent)
self._verify_profile_image_data(data, has_profile_image)
assert self.user.username == data['username']
# additional shareable fields (8)
@@ -1271,11 +1272,11 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
assert data['requires_parental_consent']
assert PRIVATE_VISIBILITY == data['account_privacy']
else:
self._verify_private_account_response(response, requires_parental_consent=True)
self._verify_private_account_response(response, requires_parental_consent=True, has_profile_image=False)
# Verify that the shared view is still private
response = self.send_get(client, query_parameters='view=shared')
self._verify_private_account_response(response, requires_parental_consent=True)
self._verify_private_account_response(response, requires_parental_consent=True, has_profile_image=False)
@skip_unless_lms

View File

@@ -1,8 +1,9 @@
# pylint: disable=missing-docstring
from datetime import date
from datetime import date, datetime
import json
from pytz import UTC
from unittest.mock import MagicMock, patch
from urllib.parse import urljoin
from django.conf import settings
@@ -20,6 +21,10 @@ from common.djangoapps.student.tests.factories import AnonymousUserFactory, User
from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file
from openedx.core.djangoapps.profile_images.images import create_profile_images
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user
TEST_PROFILE_IMAGE_UPLOAD_DT = datetime(2002, 1, 9, 15, 43, 1, tzinfo=UTC)
class CookieTests(TestCase):
@@ -27,6 +32,8 @@ class CookieTests(TestCase):
super().setUp()
self.user = UserFactory.create()
self.user.profile = UserProfileFactory.create(user=self.user)
self.user.profile.profile_image_uploaded_at = TEST_PROFILE_IMAGE_UPLOAD_DT
self.user.profile.save() # lint-amnesty, pylint: disable=no-member
self.request = RequestFactory().get('/')
self.request.user = self.user
self.request.session = self._get_stub_session()
@@ -43,18 +50,6 @@ class CookieTests(TestCase):
return urls_obj
def _get_expected_image_urls(self):
expected_image_urls = {
'full': '/static/default_500.png',
'large': '/static/default_120.png',
'medium': '/static/default_50.png',
'small': '/static/default_30.png'
}
expected_image_urls = self._convert_to_absolute_uris(self.request, expected_image_urls)
return expected_image_urls
def _get_expected_header_urls(self):
expected_header_urls = {
'logout': reverse('logout'),
@@ -112,7 +107,7 @@ class CookieTests(TestCase):
'username': self.user.username,
'email': self.user.email,
'header_urls': self._get_expected_header_urls(),
'user_image_urls': self._get_expected_image_urls(),
'user_image_urls': get_profile_image_urls_for_user(self.user),
}
self.assertDictEqual(actual, expected)

View File

@@ -2,12 +2,12 @@
JWT Token handling and signing functions.
"""
import json
import jwt
from time import time
from django.conf import settings
from jwkest import Expired, Invalid, MissingKey, jwk
from jwkest.jws import JWS
from jwt.api_jwk import PyJWK, PyJWKSet
from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, MissingRequiredClaimError
def create_jwt(lms_user_id, expires_in_seconds, additional_token_claims, now=None):
@@ -40,15 +40,9 @@ def _encode_and_sign(payload):
The signing key and algorithm are pulled from settings.
"""
keys = jwk.KEYS()
serialized_keypair = json.loads(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK'])
keys.add(serialized_keypair)
private_key = PyJWK.from_json(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK'])
algorithm = settings.TOKEN_SIGNING['JWT_SIGNING_ALGORITHM']
data = json.dumps(payload)
jws = JWS(data, alg=algorithm)
return jws.sign_compact(keys=keys)
return jwt.encode(payload, key=private_key.key, algorithm=algorithm)
def unpack_jwt(token, lms_user_id, now=None):
@@ -65,27 +59,40 @@ def unpack_jwt(token, lms_user_id, now=None):
Returns a valid, decoded json payload (string).
"""
now = now or int(time())
payload = _unpack_and_verify(token)
payload = unpack_and_verify(token)
if "lms_user_id" not in payload:
raise MissingKey("LMS user id is missing")
raise MissingRequiredClaimError("LMS user id is missing")
if "exp" not in payload:
raise MissingKey("Expiration is missing")
raise MissingRequiredClaimError("Expiration is missing")
if payload["lms_user_id"] != lms_user_id:
raise Invalid("User does not match")
raise InvalidSignatureError("User does not match")
if payload["exp"] < now:
raise Expired("Token is expired")
raise ExpiredSignatureError("Token is expired")
return payload
def _unpack_and_verify(token):
def unpack_and_verify(token): # pylint: disable=inconsistent-return-statements
"""
Unpack and verify the provided token.
The signing key and algorithm are pulled from settings.
"""
keys = jwk.KEYS()
keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET'])
decoded = JWS().verify_compact(token.encode('utf-8'), keys)
return decoded
key_set = []
key_set.extend(
PyJWKSet.from_json(settings.TOKEN_SIGNING["JWT_PUBLIC_SIGNING_JWK_SET"]).keys
)
for i in range(len(key_set)): # pylint: disable=consider-using-enumerate
try:
decoded = jwt.decode(
token,
key=key_set[i].key,
algorithms=["RS256", "RS512"],
options={"verify_signature": True, "verify_aud": False},
)
return decoded
except Exception: # pylint: disable=broad-exception-caught
if i == len(key_set) - 1:
raise

View File

@@ -2,24 +2,23 @@
Tests for token handling
"""
import unittest
from time import time
from django.conf import settings
from jwkest import BadSignature, Expired, Invalid, MissingKey, jwk
from jwkest.jws import JWS
from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, MissingRequiredClaimError
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt
from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt, unpack_and_verify
test_user_id = 121
invalid_test_user_id = 120
test_timeout = 60
test_now = 1661432902
test_timeout = 1000
test_now = int(time())
test_claims = {"foo": "bar", "baz": "quux", "meaning": 42}
expected_full_token = {
"lms_user_id": test_user_id,
"iat": 1661432902,
"exp": 1661432902 + 60,
"iat": test_now,
"exp": test_now + test_timeout,
"iss": "token-test-issuer", # these lines from test_settings.py
"version": "1.2.0", # these lines from test_settings.py
}
@@ -34,7 +33,7 @@ class TestSign(unittest.TestCase):
def test_create_jwt(self):
token = create_jwt(test_user_id, test_timeout, {}, test_now)
decoded = _verify_jwt(token)
decoded = unpack_and_verify(token)
self.assertEqual(expected_full_token, decoded)
def test_create_jwt_with_claims(self):
@@ -43,7 +42,7 @@ class TestSign(unittest.TestCase):
expected_token_with_claims = expected_full_token.copy()
expected_token_with_claims.update(test_claims)
decoded = _verify_jwt(token)
decoded = unpack_and_verify(token)
self.assertEqual(expected_token_with_claims, decoded)
def test_malformed_token(self):
@@ -53,19 +52,8 @@ class TestSign(unittest.TestCase):
expected_token_with_claims = expected_full_token.copy()
expected_token_with_claims.update(test_claims)
with self.assertRaises(BadSignature):
_verify_jwt(token)
def _verify_jwt(jwt_token):
"""
Helper function which verifies the signature and decodes the token
from string back to claims form
"""
keys = jwk.KEYS()
keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET'])
decoded = JWS().verify_compact(jwt_token.encode('utf-8'), keys)
return decoded
with self.assertRaises(InvalidSignatureError):
unpack_and_verify(token)
@skip_unless_lms
@@ -97,19 +85,19 @@ class TestUnpack(unittest.TestCase):
expected_token_with_claims = expected_full_token.copy()
expected_token_with_claims.update(test_claims)
with self.assertRaises(BadSignature):
with self.assertRaises(InvalidSignatureError):
unpack_jwt(token, test_user_id, test_now)
def test_unpack_token_with_invalid_user(self):
token = create_jwt(invalid_test_user_id, test_timeout, {}, test_now)
with self.assertRaises(Invalid):
with self.assertRaises(InvalidSignatureError):
unpack_jwt(token, test_user_id, test_now)
def test_unpack_expired_token(self):
token = create_jwt(test_user_id, test_timeout, {}, test_now)
with self.assertRaises(Expired):
with self.assertRaises(ExpiredSignatureError):
unpack_jwt(token, test_user_id, test_now + test_timeout + 1)
def test_missing_expired_lms_user_id(self):
@@ -117,7 +105,7 @@ class TestUnpack(unittest.TestCase):
del payload['lms_user_id']
token = _encode_and_sign(payload)
with self.assertRaises(MissingKey):
with self.assertRaises(MissingRequiredClaimError):
unpack_jwt(token, test_user_id, test_now)
def test_missing_expired_key(self):
@@ -125,5 +113,5 @@ class TestUnpack(unittest.TestCase):
del payload['exp']
token = _encode_and_sign(payload)
with self.assertRaises(MissingKey):
with self.assertRaises(MissingRequiredClaimError):
unpack_jwt(token, test_user_id, test_now)

View File

@@ -51,7 +51,7 @@ django-stubs<6
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
edx-enterprise==6.0.3
edx-enterprise==6.2.5
# Date: 2023-07-26
# Our legacy Sass code is incompatible with anything except this ancient libsass version.

View File

@@ -421,7 +421,7 @@ edx-celeryutils==1.4.0
# -r requirements/edx/kernel.in
# edx-name-affirmation
# super-csv
edx-codejail==3.5.2
edx-codejail==4.0.0
# via -r requirements/edx/kernel.in
edx-completion==4.9
# via -r requirements/edx/kernel.in
@@ -461,7 +461,7 @@ edx-drf-extensions==10.6.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==6.0.3
edx-enterprise==6.2.5
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in

View File

@@ -691,7 +691,7 @@ edx-celeryutils==1.4.0
# -r requirements/edx/testing.txt
# edx-name-affirmation
# super-csv
edx-codejail==3.5.2
edx-codejail==4.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -740,7 +740,7 @@ edx-drf-extensions==10.6.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==6.0.3
edx-enterprise==6.2.5
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt

View File

@@ -505,7 +505,7 @@ edx-celeryutils==1.4.0
# -r requirements/edx/base.txt
# edx-name-affirmation
# super-csv
edx-codejail==3.5.2
edx-codejail==4.0.0
# via -r requirements/edx/base.txt
edx-completion==4.9
# via -r requirements/edx/base.txt
@@ -545,7 +545,7 @@ edx-drf-extensions==10.6.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==6.0.3
edx-enterprise==6.2.5
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt

View File

@@ -66,7 +66,8 @@ edx-celeryutils
edx-completion
edx-django-release-util # Release utils for the edx release pipeline
edx-django-sites-extensions
edx-codejail
# Codejail 4 brings important safety improvements (no unsafe mode by default)
edx-codejail>=4.0.0
# edx-django-utils 5.14.1 adds FrontendMonitoringMiddleware
edx-django-utils>=5.14.1 # Utilities for cache, monitoring, and plugins
edx-drf-extensions

View File

@@ -530,7 +530,7 @@ edx-celeryutils==1.4.0
# -r requirements/edx/base.txt
# edx-name-affirmation
# super-csv
edx-codejail==3.5.2
edx-codejail==4.0.0
# via -r requirements/edx/base.txt
edx-completion==4.9
# via -r requirements/edx/base.txt
@@ -570,7 +570,7 @@ edx-drf-extensions==10.6.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==6.0.3
edx-enterprise==6.2.5
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt

View File

@@ -24,8 +24,10 @@ from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.capa.safe_exec import safe_exec, update_hash
from xmodule.capa.safe_exec.remote_exec import is_codejail_in_darklaunch, is_codejail_rest_service_enabled
from xmodule.capa.safe_exec.safe_exec import emsg_normalizers, normalize_error_message
from xmodule.capa.tests.test_util import use_unsafe_codejail
@use_unsafe_codejail()
class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
def test_set_values(self):
g = {}
@@ -530,6 +532,7 @@ class DictCache(object):
self.cache[key] = value
@use_unsafe_codejail()
class TestSafeExecCaching(unittest.TestCase):
"""Test that caching works on safe_exec."""
@@ -654,6 +657,7 @@ class TestUpdateHash(unittest.TestCase):
assert h1 == h2
@use_unsafe_codejail()
class TestRealProblems(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
def test_802x(self):
code = textwrap.dedent("""\

View File

@@ -15,6 +15,7 @@ from markupsafe import Markup
from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.responsetypes import LoncapaProblemError
from xmodule.capa.tests.helpers import new_loncapa_problem
from xmodule.capa.tests.test_util import use_unsafe_codejail
from openedx.core.djangolib.markup import HTML
@@ -23,6 +24,7 @@ FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS['ENABLE_GRADING_METHOD_IN_PROBLEMS'] =
@ddt.ddt
@use_unsafe_codejail()
class CAPAProblemTest(unittest.TestCase):
""" CAPA problem related tests"""
@@ -424,6 +426,7 @@ class CAPAProblemTest(unittest.TestCase):
@ddt.ddt
@use_unsafe_codejail()
class CAPAMultiInputProblemTest(unittest.TestCase):
""" TestCase for CAPA problems with multiple inputtypes """

View File

@@ -11,12 +11,14 @@ from unittest import mock
import ddt
from lxml import etree
from xmodule.capa.tests.helpers import new_loncapa_problem, mock_capa_system
from xmodule.capa.tests.test_util import use_unsafe_codejail
from openedx.core.djangolib.markup import HTML
from .response_xml_factory import CustomResponseXMLFactory, StringResponseXMLFactory
@ddt.ddt
@use_unsafe_codejail()
class CapaHtmlRenderTest(unittest.TestCase):
"""
CAPA HTML rendering tests class.

View File

@@ -37,6 +37,7 @@ from xmodule.capa.tests.response_xml_factory import (
SymbolicResponseXMLFactory,
TrueFalseResponseXMLFactory
)
from xmodule.capa.tests.test_util import use_unsafe_codejail
from xmodule.capa.util import convert_files_to_filenames
from xmodule.capa.xqueue_interface import dateformat
@@ -108,6 +109,7 @@ class ResponseTest(unittest.TestCase):
return str(rand.randint(0, 1e9))
@use_unsafe_codejail()
class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
xml_factory_class = MultipleChoiceResponseXMLFactory
@@ -375,6 +377,7 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
assert correct_map.get_correctness('1_2_1') == expected_correctness
@use_unsafe_codejail()
class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
xml_factory_class = OptionResponseXMLFactory
@@ -422,6 +425,7 @@ class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_property('1_2_1', 'answervariable') == '$a'
@use_unsafe_codejail()
class FormulaResponseTest(ResponseTest):
"""
Test the FormulaResponse class
@@ -571,6 +575,7 @@ class FormulaResponseTest(ResponseTest):
assert not list(problem.responders.values())[0].validate_answer('3*y+2*x')
@use_unsafe_codejail()
class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
xml_factory_class = StringResponseXMLFactory
@@ -1124,6 +1129,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
assert output[answer_id]['msg'] == 'Invalid grader reply. Please contact the course staff.'
@use_unsafe_codejail()
class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
xml_factory_class = ChoiceResponseXMLFactory
@@ -1292,6 +1298,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, ['choice_1', 'choice_3'], 'incorrect')
@use_unsafe_codejail()
class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
xml_factory_class = NumericalResponseXMLFactory
@@ -1680,6 +1687,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
assert not responder.validate_answer('fish')
@use_unsafe_codejail()
class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
xml_factory_class = CustomResponseXMLFactory
@@ -2399,6 +2407,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_msg('1_2_11') == '11'
@use_unsafe_codejail()
class SchematicResponseTest(ResponseTest):
"""
Class containing setup and tests for Schematic responsetype.
@@ -2488,6 +2497,7 @@ class AnnotationResponseTest(ResponseTest): # lint-amnesty, pylint: disable=mis
assert expected_points == actual_points, ('%s should have %d points' % (answer_id, expected_points))
@use_unsafe_codejail()
class ChoiceTextResponseTest(ResponseTest):
"""
Class containing setup and tests for ChoiceText responsetype.

View File

@@ -6,7 +6,9 @@ Tests capa util
import unittest
import codejail.safe_exec
import ddt
from django.test.utils import TestContextDecorator
from lxml import etree
from xmodule.capa.tests.helpers import mock_capa_system
@@ -167,3 +169,28 @@ class UtilTest(unittest.TestCase):
expected_text = '$あなたあなたあなたあなた あなたhi'
contextual_text = contextualize_text(text, context)
assert expected_text == contextual_text
class use_unsafe_codejail(TestContextDecorator):
"""
Tell codejail to run in unsafe mode for the scope of the decorator.
Use this as a decorator on Django TestCase classes or methods.
This is needed because codejail has significant OS-level setup requirements
which we don't even attempt to fulfill for unit testing purposes. Running
tests in unsafe mode (that is, running code executions in-process, with no
sandboxing) is only safe because we control the contents of the unit tests.
It's not a perfect replica of how safe mode operates but it's generally good
enough for testing the integration and overall behavior.
"""
def __init__(self):
self.old_be_unsafe = None
super().__init__()
def enable(self):
self.old_be_unsafe = codejail.safe_exec.ALWAYS_BE_UNSAFE
codejail.safe_exec.ALWAYS_BE_UNSAFE = True
def disable(self):
codejail.safe_exec.ALWAYS_BE_UNSAFE = self.old_be_unsafe

View File

@@ -37,6 +37,7 @@ from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, Stude
from xmodule.capa.xqueue_interface import XQueueInterface
from xmodule.capa_block import ComplexEncoder, ProblemBlock
from xmodule.tests import DATA_DIR
from xmodule.capa.tests.test_util import use_unsafe_codejail
from ..capa_block import RANDOMIZATION, SHOWANSWER
from . import get_test_system
@@ -3635,6 +3636,7 @@ class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=mi
@skip_unless_lms
@use_unsafe_codejail()
class ProblemCheckTrackingTest(unittest.TestCase):
"""
Ensure correct tracking information is included in events emitted during problem checks.