<%- gettext('Not Currently Available') %>
diff --git a/mypy.ini b/mypy.ini
index f0267364ff..72937e1034 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -9,7 +9,6 @@ files =
cms/lib/xblock/upstream_sync.py,
cms/lib/xblock/upstream_sync_container.py,
cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py,
- cms/djangoapps/import_from_modulestore,
openedx/core/djangoapps/content/learning_sequences,
# FIXME: need to solve type issues and add 'search' app here:
# openedx/core/djangoapps/content/search,
diff --git a/openedx/core/djangoapps/courseware_api/serializers.py b/openedx/core/djangoapps/courseware_api/serializers.py
index 191d832c5f..c4b6476cb7 100644
--- a/openedx/core/djangoapps/courseware_api/serializers.py
+++ b/openedx/core/djangoapps/courseware_api/serializers.py
@@ -73,10 +73,20 @@ class CourseProgramSerializer(serializers.Serializer): # lint-amnesty, pylint:
}
+class PrerequisiteCourseSerializer(serializers.Serializer):
+ """
+ Serializer for prerequisite course data with the serialized course key and display name.
+ """
+ key = serializers.CharField()
+ display = serializers.CharField()
+
+
class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer for Course objects providing minimal data about the course.
- Compare this with CourseDetailSerializer.
+
+ For detailed information about what each field is for, see the docstring of the
+ CoursewareInformation class.
"""
access_expiration = serializers.DictField()
@@ -115,6 +125,40 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-
is_integrity_signature_enabled = serializers.BooleanField()
user_needs_integrity_signature = serializers.BooleanField()
learning_assistant_enabled = serializers.BooleanField()
+ show_courseware_link = serializers.BooleanField()
+ is_course_full = serializers.BooleanField()
+ can_enroll = serializers.BooleanField()
+ invitation_only = serializers.BooleanField()
+ is_shib_course = serializers.BooleanField()
+ allow_anonymous = serializers.BooleanField()
+ ecommerce_checkout = serializers.BooleanField()
+ single_paid_mode = serializers.DictField()
+ ecommerce_checkout_link = AbsoluteURLField(allow_null=True)
+ course_image_urls = serializers.ListField(
+ child=serializers.CharField(),
+ allow_empty=True,
+ default=list,
+ )
+ start_date_is_still_default = serializers.BooleanField()
+ advertised_start = serializers.CharField()
+ course_price = serializers.CharField()
+ pre_requisite_courses = serializers.ListField(
+ child=PrerequisiteCourseSerializer(),
+ allow_empty=True,
+ default=list,
+ )
+ about_sidebar_html = serializers.CharField(
+ allow_blank=True,
+ allow_null=True,
+ default=None,
+ )
+ display_number_with_default = serializers.CharField()
+ display_org_with_default = serializers.CharField()
+ overview = serializers.CharField(
+ allow_blank=True,
+ allow_null=True,
+ default=None,
+ )
def __init__(self, *args, **kwargs):
"""
diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py
index ef2f9ef247..1606d245c0 100644
--- a/openedx/core/djangoapps/courseware_api/tests/test_views.py
+++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py
@@ -15,7 +15,7 @@ from django.test import override_settings
from django.test.client import RequestFactory
from edx_django_utils.cache import TieredCache
-from edx_toggles.toggles.testutils import override_waffle_flag
+from edx_toggles.toggles.testutils import override_waffle_flag, override_waffle_switch
from xmodule.data import CertificatesDisplayBehaviors
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
@@ -45,7 +45,7 @@ from common.djangoapps.student.roles import CourseInstructorRole
from common.djangoapps.student.tests.factories import CourseEnrollmentCelebrationFactory, UserFactory
from openedx.core.djangoapps.agreements.api import create_integrity_signature
from openedx.core.djangolib.testing.utils import skip_unless_lms
-
+from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
User = get_user_model()
@@ -611,3 +611,257 @@ class CelebrationApiTestViews(BaseCoursewareTests, MasqueradeMixin):
# make sure they didn't change during masquerade attempt
assert celebration.celebrate_first_section
assert not celebration.celebrate_weekly_goal
+
+
+@ddt.ddt
+@skip_unless_lms # If run in CMS, the tests fail as the courseware_api.views module contains imports from the LMS.
+class CoursewareMetaTestViews(BaseCoursewareTests):
+ """
+ Tests for the CoursewareMeta class
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.course_enrollment = CourseEnrollment.enroll(self.user, self.course.id, 'audit')
+ self.request = RequestFactory().get(self.url)
+
+ def create_courseware_meta(self, user=None):
+ """
+ Helper method to create CoursewareMeta instance
+ """
+ from openedx.core.djangoapps.courseware_api.views import CoursewareMeta
+
+ user = user or self.user
+ self.request.user = user
+ return CoursewareMeta(self.course.id, self.request, username=user.username)
+
+ @ddt.data(True, False)
+ def test_is_course_full_property(self, is_course_full):
+ """
+ Test is_course_full property
+ """
+ with mock.patch(
+ 'openedx.core.djangoapps.courseware_api.views.CourseEnrollment.objects.is_course_full'
+ ) as mock_is_course_full:
+ mock_is_course_full.return_value = is_course_full
+ meta = self.create_courseware_meta()
+ assert meta.is_course_full is is_course_full
+
+ @ddt.data(True, False)
+ def test_invitation_only_property(self, invitation_only):
+ """
+ Test invitation_only property
+ """
+ with override_settings(COURSES_INVITE_ONLY=invitation_only):
+ meta = self.create_courseware_meta()
+ assert meta.invitation_only is invitation_only
+
+ @ddt.data(True, False)
+ @mock.patch(
+ 'openedx.core.djangoapps.courseware_api.views.get_course_about_section', new_callable=mock.PropertyMock
+ )
+ def test_about_sidebar_html_property(self, waffle_enabled, mock_get_course_about_section):
+ """
+ Test about_sidebar_html property with different waffle settings
+ """
+ mock_get_course_about_section.return_value = '
About Course
'
+ with override_waffle_switch(ENABLE_COURSE_ABOUT_SIDEBAR_HTML, active=waffle_enabled):
+ meta = self.create_courseware_meta()
+ if waffle_enabled:
+ assert meta.about_sidebar_html == '
About Course
'
+ else:
+ assert meta.about_sidebar_html is None
+
+
+@ddt.ddt
+@skip_unless_lms
+class CoursewareMetaAPIResponseTestViews(BaseCoursewareTests):
+ """
+ Tests for API response fields returned by CoursewareMeta through the API endpoint
+ """
+
+ def setUp(self):
+ super().setUp()
+ CourseEnrollment.enroll(self.user, self.course.id, 'audit')
+
+ def test_api_returns_show_courseware_link_field(self):
+ """
+ Test that API response contains show_courseware_link field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'show_courseware_link' in response.data
+ assert isinstance(response.data['show_courseware_link'], bool)
+
+ def test_api_returns_is_course_full_field(self):
+ """
+ Test that API response contains is_course_full field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'is_course_full' in response.data
+ assert isinstance(response.data['is_course_full'], bool)
+
+ def test_api_returns_can_enroll_field(self):
+ """
+ Test that API response contains can_enroll field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'can_enroll' in response.data
+ assert isinstance(response.data['can_enroll'], bool)
+
+ def test_api_returns_invitation_only_field(self):
+ """
+ Test that API response contains invitation_only field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'invitation_only' in response.data
+ assert isinstance(response.data['invitation_only'], bool)
+
+ def test_api_returns_is_shib_course_field(self):
+ """
+ Test that API response contains is_shib_course field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'is_shib_course' in response.data
+ assert isinstance(response.data['is_shib_course'], bool)
+
+ def test_api_returns_allow_anonymous_field(self):
+ """
+ Test that API response contains allow_anonymous field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'allow_anonymous' in response.data
+ assert isinstance(response.data['allow_anonymous'], bool)
+
+ def test_api_returns_ecommerce_checkout_field(self):
+ """
+ Test that API response contains ecommerce_checkout field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'ecommerce_checkout' in response.data
+ assert isinstance(response.data['ecommerce_checkout'], bool)
+
+ def test_api_returns_single_paid_mode_field(self):
+ """
+ Test that API response contains single_paid_mode field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'single_paid_mode' in response.data
+ assert isinstance(response.data['single_paid_mode'], dict)
+
+ def test_api_returns_ecommerce_checkout_link_field(self):
+ """
+ Test that API response contains ecommerce_checkout_link field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'ecommerce_checkout_link' in response.data
+ checkout_link = response.data['ecommerce_checkout_link']
+ assert isinstance(checkout_link, str) or checkout_link is None
+
+ def test_api_returns_course_image_urls_field(self):
+ """
+ Test that API response contains course_image_urls field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'course_image_urls' in response.data
+ assert isinstance(response.data['course_image_urls'], list)
+
+ def test_api_returns_start_date_is_still_default_field(self):
+ """
+ Test that API response contains start_date_is_still_default field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'start_date_is_still_default' in response.data
+ assert isinstance(response.data['start_date_is_still_default'], bool)
+
+ def test_api_returns_advertised_start_field(self):
+ """
+ Test that API response contains advertised_start field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'advertised_start' in response.data
+ advertised_start = response.data['advertised_start']
+ assert isinstance(advertised_start, str) or advertised_start is None
+
+ def test_api_returns_course_price_field(self):
+ """
+ Test that API response contains course_price field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'course_price' in response.data
+ assert isinstance(response.data['course_price'], str)
+
+ def test_api_returns_pre_requisite_courses_field(self):
+ """
+ Test that API response contains pre_requisite_courses field
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'pre_requisite_courses' in response.data
+ assert isinstance(response.data['pre_requisite_courses'], list)
+
+ @ddt.data(True, False)
+ @mock.patch(
+ 'openedx.core.djangoapps.courseware_api.views.get_course_about_section', new_callable=mock.PropertyMock
+ )
+ def test_api_about_sidebar_html_with_waffle(self, waffle_enabled, mock_get_course_about_section):
+ """
+ Test API returns correct about_sidebar_html value based on waffle flag
+ """
+ with override_waffle_switch(ENABLE_COURSE_ABOUT_SIDEBAR_HTML, active=waffle_enabled):
+ mock_get_course_about_section.return_value = '
About Course
'
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert 'about_sidebar_html' in response.data
+ if waffle_enabled:
+ assert response.data['about_sidebar_html'] == '
About Course
'
+ else:
+ assert response.data['about_sidebar_html'] is None
+
+
+@ddt.ddt
+@skip_unless_lms
+class CoursewareMetaIntegrationTestViews(BaseCoursewareTests):
+ """
+ Integration tests for CoursewareMeta with different user states and course configurations
+ """
+
+ @ddt.data(
+ ('audit', False),
+ ('verified', True),
+ ('honor', True),
+ ('professional', True),
+ )
+ @ddt.unpack
+ def test_enrollment_mode_affects_can_access_proctored_exams(self, enrollment_mode, expected_access):
+ """
+ Test that enrollment mode affects proctored exam access in API response
+ """
+ CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode)
+
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert response.data['can_access_proctored_exams'] == expected_access
+
+ @mock.patch('openedx.core.djangoapps.courseware_api.views.check_public_access')
+ def test_public_course_affects_allow_anonymous(self, mock_check_public_access):
+ """
+ Test that course visibility settings affect allow_anonymous field
+ """
+ mock_check_public_access.return_value = ACCESS_GRANTED
+
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ assert response.data['allow_anonymous'] is True
diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py
index 6b5e12257b..ee37835b48 100644
--- a/openedx/core/djangoapps/courseware_api/views.py
+++ b/openedx/core/djangoapps/courseware_api/views.py
@@ -25,15 +25,22 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.modulestore.search import path_to_location
from xmodule.x_module import PUBLIC_VIEW, STUDENT_VIEW
-from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.course_modes.models import CourseMode, get_course_prices
from common.djangoapps.util.views import expose_header
from lms.djangoapps.edxnotes.helpers import is_feature_enabled
from lms.djangoapps.certificates.api import get_certificate_url
from lms.djangoapps.certificates.models import GeneratedCertificate
+from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.course_api.api import course_detail
from lms.djangoapps.course_goals.models import UserActivity
from lms.djangoapps.course_goals.api import get_course_goal
from lms.djangoapps.courseware.access import has_access
+from lms.djangoapps.courseware.access_utils import check_public_access
+from lms.djangoapps.courseware.courses import (
+ get_course_about_section,
+ get_course_with_access,
+ get_permission_for_course_about,
+)
from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
from lms.djangoapps.courseware.entrance_exams import course_has_entrance_exam, user_has_passed_entrance_exam
@@ -43,19 +50,24 @@ from lms.djangoapps.courseware.masquerade import (
is_masquerading_as_non_audit_enrollment,
)
from lms.djangoapps.courseware.models import LastSeenCoursewareTimezone
+from lms.djangoapps.courseware.permissions import VIEW_COURSEWARE
from lms.djangoapps.courseware.block_render import get_block_by_usage_id
-from lms.djangoapps.courseware.toggles import course_exit_page_is_active
+from lms.djangoapps.courseware.toggles import course_exit_page_is_active, course_is_invitation_only
from lms.djangoapps.courseware.views.views import get_cert_data
from lms.djangoapps.gating.api import get_entrance_exam_score, get_entrance_exam_usage_key
from lms.djangoapps.grades.api import CourseGradeFactory
+from lms.djangoapps.instructor.enrollment import uses_shib
+from common.djangoapps.util.milestones_helpers import get_prerequisite_courses_display
from lms.djangoapps.verify_student.services import IDVerificationService
from openedx.core.djangoapps.agreements.api import get_integrity_signature
from openedx.core.djangoapps.courseware_api.utils import get_celebrations_dict
+from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.lib.courses import get_course_by_id
from openedx.features.course_experience import ENABLE_COURSE_GOALS
+from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.course_duration_limits.access import get_access_expiration_data
from openedx.features.discounts.utils import generate_offer_data
@@ -64,6 +76,10 @@ from common.djangoapps.student.models import (
CourseEnrollmentCelebration,
LinkedInAddToProfileConfiguration
)
+from xmodule.course_block import (
+ COURSE_VISIBILITY_PUBLIC,
+ COURSE_VISIBILITY_PUBLIC_OUTLINE,
+)
from .serializers import CourseInfoSerializer
@@ -75,13 +91,13 @@ class CoursewareMeta:
def __init__(self, course_key, request, username=''):
self.request = request
- self.overview = course_detail(
+ self.course_overview = course_detail(
self.request,
username or self.request.user.username,
course_key,
)
- original_user_is_staff = has_access(self.request.user, 'staff', self.overview).has_access
+ original_user_is_staff = has_access(self.request.user, 'staff', self.course_overview).has_access
self.original_user_is_global_staff = self.request.user.is_staff
self.course_key = course_key
self.course = get_course_by_id(self.course_key)
@@ -91,12 +107,13 @@ class CoursewareMeta:
staff_access=original_user_is_staff,
)
self.request.user = self.effective_user
- self.overview.bind_course_for_student(self.request)
+ self.course_overview.bind_course_for_student(self.request)
self.enrollment_object = CourseEnrollment.get_enrollment(self.effective_user, self.course_key,
select_related=['celebration', 'user__celebration'])
+ self.ecomm_service = EcommerceService()
def __getattr__(self, name):
- return getattr(self.overview, name)
+ return getattr(self.course_overview, name)
@property
def enrollment(self):
@@ -113,11 +130,11 @@ class CoursewareMeta:
@property
def access_expiration(self):
- return get_access_expiration_data(self.effective_user, self.overview)
+ return get_access_expiration_data(self.effective_user, self.course_overview)
@property
def offer(self):
- return generate_offer_data(self.effective_user, self.overview)
+ return generate_offer_data(self.effective_user, self.course_overview)
@property
def content_type_gating_enabled(self):
@@ -140,8 +157,8 @@ class CoursewareMeta:
Return whether edxnotes is enabled and visible.
"""
return {
- 'enabled': is_feature_enabled(self.overview, self.effective_user),
- 'visible': self.overview.edxnotes_visibility,
+ 'enabled': is_feature_enabled(self.course_overview, self.effective_user),
+ 'visible': self.course_overview.edxnotes_visibility,
}
@property
@@ -214,12 +231,12 @@ class CoursewareMeta:
"""
return {
'entrance_exam_current_score': get_entrance_exam_score(
- self.course_grade, get_entrance_exam_usage_key(self.overview),
+ self.course_grade, get_entrance_exam_usage_key(self.course_overview),
),
- 'entrance_exam_enabled': course_has_entrance_exam(self.overview),
- 'entrance_exam_id': self.overview.entrance_exam_id,
- 'entrance_exam_minimum_score_pct': self.overview.entrance_exam_minimum_score_pct,
- 'entrance_exam_passed': user_has_passed_entrance_exam(self.effective_user, self.overview),
+ 'entrance_exam_enabled': course_has_entrance_exam(self.course_overview),
+ 'entrance_exam_id': self.course_overview.entrance_exam_id,
+ 'entrance_exam_minimum_score_pct': self.course_overview.entrance_exam_minimum_score_pct,
+ 'entrance_exam_passed': user_has_passed_entrance_exam(self.effective_user, self.course_overview),
}
@property
@@ -271,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.overview.display_name, user_certificate.mode, cert_url, certificate=user_certificate,
+ self.course_overview.display_name, user_certificate.mode, cert_url, certificate=user_certificate,
)
@property
@@ -369,6 +386,146 @@ class CoursewareMeta:
"""
return getattr(settings, 'LEARNING_ASSISTANT_AVAILABLE', False)
+ @property
+ def show_courseware_link(self):
+ """
+ Returns a boolean representing whether the courseware link should be shown in the course details page.
+ """
+ with modulestore().bulk_operations(self.course_key):
+ permission = get_permission_for_course_about()
+ course_with_access = get_course_with_access(self.request.user, permission, self.course_key)
+ return bool(
+ self.request.user.has_perm(VIEW_COURSEWARE, course_with_access)
+ or settings.FEATURES.get('ENABLE_LMS_MIGRATION')
+ )
+
+ @property
+ def is_course_full(self):
+ """
+ Returns a boolean representing whether the course is full.
+ """
+ return CourseEnrollment.objects.is_course_full(self.course)
+
+ @property
+ def can_enroll(self):
+ """
+ Returns a boolean representing whether the user can enroll in the course.
+ """
+ return bool(self.request.user.has_perm(ENROLL_IN_COURSE, self.course))
+
+ @property
+ def invitation_only(self):
+ """
+ Returns a boolean representing whether the course is invitation only.
+ """
+ return course_is_invitation_only(self.course)
+
+ @property
+ def is_shib_course(self):
+ """
+ Returns a boolean representing whether the course is a Shibboleth course.
+ """
+ return uses_shib(self.course)
+
+ @property
+ def allow_anonymous(self):
+ """
+ Returns a boolean representing whether the course allows anonymous access.
+ """
+ return bool(check_public_access(self.course, [COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE]))
+
+ @property
+ def ecommerce_checkout(self):
+ """
+ Returns a boolean representing whether the course has an ecommerce checkout.
+ """
+ return self.ecomm_service.is_enabled(self.request.user)
+
+ @property
+ def single_paid_mode(self):
+ """
+ Returns a dict representing the single paid mode for the course, if it exists.
+ """
+ modes = CourseMode.modes_for_course_dict(self.course_key)
+ single_paid_mode = {}
+ if self.ecommerce_checkout:
+ if len(modes) == 1 and list(modes.values())[0].min_price:
+ single_paid_mode = list(modes.values())[0]
+ else:
+ # have professional ignore other modes for historical reasons
+ single_paid_mode = modes.get(CourseMode.PROFESSIONAL)
+ if single_paid_mode:
+ return {
+ "sku": single_paid_mode.sku,
+ "name": single_paid_mode.name,
+ "min_price": single_paid_mode.min_price,
+ "description": single_paid_mode.description,
+ }
+ return {}
+
+ @property
+ def ecommerce_checkout_link(self):
+ """
+ Returns the ecommerce checkout link for the course.
+ """
+ if self.single_paid_mode and self.single_paid_mode.sku:
+ return self.ecomm_service.get_checkout_page_url(
+ self.single_paid_mode.sku, course_run_keys=[self.course_key]
+ )
+ return None
+
+ @property
+ def course_image_urls(self):
+ """
+ Returns a list of course image URLs.
+ """
+ return self.course_overview.image_urls
+
+ @property
+ def start_date_is_still_default(self):
+ """
+ Returns a boolean indicating whether the course start date is still the default value.
+ """
+ return self.course_overview.start_date_is_still_default
+
+ @property
+ def advertised_start(self):
+ """
+ Returns the advertised start date of the course.
+ """
+ return self.course_overview.advertised_start
+
+ @property
+ def course_price(self):
+ """
+ Returns the course price, formatted with the currency symbol.
+ """
+ _, course_price = get_course_prices(self.course)
+ return course_price
+
+ @property
+ def pre_requisite_courses(self):
+ """
+ Returns a list of pre-requisite courses for the course.
+ """
+ return get_prerequisite_courses_display(self.course)
+
+ @property
+ def about_sidebar_html(self):
+ """
+ Returns the HTML content for the course about section.
+ """
+ if ENABLE_COURSE_ABOUT_SIDEBAR_HTML.is_enabled():
+ return get_course_about_section(self.request, self.course, "about_sidebar_html")
+ return None
+
+ @property
+ def overview(self):
+ """
+ Returns the overview HTML content for the course.
+ """
+ return get_course_about_section(self.request, self.course, "overview")
+
@method_decorator(transaction.non_atomic_requests, name='dispatch')
class CoursewareInformation(RetrieveAPIView):
@@ -458,9 +615,45 @@ class CoursewareInformation(RetrieveAPIView):
* certificate_data: data regarding the effective user's certificate for the given course
* verify_identity_url: URL for a learner to verify their identity. Only returned for learners enrolled in a
verified mode. Will update to reverify URL if necessary.
+ * verification_status: The verification status of the effective user in the course. Possible values:
+ * 'none': No verification has been created for the user
+ * 'expired': The verification has expired
+ * 'approved': The verification has been approved
+ * 'pending': The verification is pending
+ * 'must_reverify': The user must reverify their identity
* linkedin_add_to_profile_url: URL to add the effective user's certificate to a LinkedIn Profile.
* user_needs_integrity_signature: Whether the user needs to sign the integrity agreement for the course
* learning_assistant_enabled: Whether the Xpert Learning Assistant is enabled for the requesting user
+ * show_courseware_link: Whether the courseware link should be shown in the course details page
+ * is_course_full: Whether the course is full
+ * can_enroll: Whether the user can enroll in the course
+ * invitation_only: Whether the course is invitation only
+ * is_shib_course: Whether the course is a Shibboleth course
+ * allow_anonymous: Whether the course allows anonymous access
+ * ecommerce_checkout: Whether the course has an ecommerce checkout
+ * single_paid_mode: An object representing the single paid mode for the course, if it exists
+ * sku: (str) The SKU for the single paid mode
+ * name: (str) The name of the single paid mode
+ * min_price: (str) The minimum price for the single paid mode, formatted with the currency symbol
+ * description: (str) The description of the single paid mode
+ * ecommerce_checkout_link: The ecommerce checkout link for the course, if it exists
+ * course_image_urls: A list of course image URLs
+ * start_date_is_still_default: Whether the course start date is still the default value
+ * advertised_start: The advertised start date of the course
+ * course_price: The course price, formatted with the currency symbol
+ * pre_requisite_courses: A list of pre-requisite courses for the course
+ * about_sidebar_html: The HTML content for the course about section, if enabled
+ * display_number_with_default: The course number with the org name, if set
+ * display_org_with_default: The org name with the course number, if set
+ * content_type_gating_enabled: Whether the content type gating is enabled for the course
+ * show_calculator: Whether the calculator should be shown in the course details page
+ * can_access_proctored_exams: Whether the user is eligible to access proctored exams
+ * notes: An object containing note settings for the course
+ * enabled: Boolean indicating whether edxnotes feature is enabled for the course
+ * visible: Boolean indicating whether notes are visible in the course
+ * marketing_url: The marketing URL for the course
+ * overview: The overview HTML content for the course
+ * license: The license for the course
**Parameters:**
diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py
index 88606b999b..a243db5c76 100644
--- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py
+++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py
@@ -287,7 +287,6 @@ class Model:
"close_reason_code": request_params.get("close_reason_code"),
"closing_user_id": request_params.get("closing_user_id"),
"endorsed": request_params.get("endorsed"),
- "read": request_params.get("read"),
}
request_data = {k: v for k, v in request_data.items() if v is not None}
response = forum_api.update_thread(**request_data)
diff --git a/openedx/core/djangoapps/notifications/management/commands/update_notification_preferences.py b/openedx/core/djangoapps/notifications/management/commands/update_notification_preferences.py
new file mode 100644
index 0000000000..2653e8b1cd
--- /dev/null
+++ b/openedx/core/djangoapps/notifications/management/commands/update_notification_preferences.py
@@ -0,0 +1,116 @@
+"""
+Management command for updating notification preferences with parameters
+"""
+import logging
+
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+
+from openedx.core.djangoapps.notifications.models import NotificationPreference
+from openedx.core.djangoapps.notifications.base_notification import (
+ COURSE_NOTIFICATION_APPS,
+ COURSE_NOTIFICATION_TYPES
+)
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ """
+ Management command to update boolean notification preferences.
+
+ This command updates channel (`web`, `push`, `email`)
+ in the NotificationPreference model for a given app and type.
+
+ Features:
+ - Requires `app` and `type`, validated against
+ COURSE_NOTIFICATION_APPS and COURSE_NOTIFICATION_TYPES.
+ - Allows updating a single channel to `true` or `false`.
+ - Supports optional `--user_ids` argument to limit updates
+ to specific users.
+ - Provides a `--dry-run` mode to preview changes without
+ committing to the database.
+ - Logs the number of affected records and affected user IDs.
+
+ Example usage:
+ python manage.py update_notification_preference discussion new_comment_on_response email false
+ python manage.py update_notification_preference discussion new_comment_on_response push false --user_ids 5 7 12
+ python manage.py update_notification_preference discussion new_comment_on_response web false --dry-run
+ """
+ help = "Update boolean notification preferences for users at account level."
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "app",
+ type=str,
+ choices=list(COURSE_NOTIFICATION_APPS.keys()),
+ help=f"App key (choices: {', '.join(COURSE_NOTIFICATION_APPS.keys())})",
+ )
+ parser.add_argument(
+ "type",
+ type=str,
+ choices=list(COURSE_NOTIFICATION_TYPES.keys()),
+ help=f"Type key (choices: {', '.join(COURSE_NOTIFICATION_TYPES.keys())})"
+ )
+ parser.add_argument(
+ "channel",
+ type=str,
+ choices=["web", "push", "email"],
+ help="channel to update"
+ )
+ parser.add_argument(
+ "value",
+ type=str,
+ choices=["true", "false"],
+ help="Boolean value (true/false)"
+ )
+ parser.add_argument(
+ "--user_ids",
+ nargs="+",
+ type=int,
+ help="Optional list of user IDs to update only",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Simulate update without saving changes",
+ )
+
+ def handle(self, *args, **options):
+ app = options["app"]
+ pref_type = options["type"]
+ channel = options["channel"]
+ value_str = options["value"].lower()
+ dry_run = options["dry_run"]
+ user_ids = options.get("user_ids")
+
+ if value_str in ["true"]:
+ new_value = True
+ elif value_str in ["false"]:
+ new_value = False
+ else:
+ raise CommandError("Value must be true/false")
+
+ queryset = NotificationPreference.objects.filter(app=app, type=pref_type)
+ if user_ids:
+ queryset = queryset.filter(user_id__in=user_ids)
+
+ queryset = queryset.exclude(**{channel: new_value}) # only ones that need updating
+
+ affected = queryset.count()
+
+ if not affected:
+ logger.info("No records to update.")
+ return
+
+ logger.info(
+ f"{affected} record(s) will be updated. "
+ )
+
+ if dry_run:
+ logger.info("Dry-run mode: no changes applied.")
+ return
+
+ with transaction.atomic():
+ updated = queryset.update(**{channel: new_value})
+ logger.info(f" Updated {updated} records.")
diff --git a/openedx/core/djangoapps/theming/tests/test_views.py b/openedx/core/djangoapps/theming/tests/test_views.py
index 5b5503702c..d4a990d11f 100644
--- a/openedx/core/djangoapps/theming/tests/test_views.py
+++ b/openedx/core/djangoapps/theming/tests/test_views.py
@@ -100,7 +100,13 @@ class TestThemingViews(TestCase):
assert response.status_code == 302
assert response.url == "/static/images/logo.png"
- @override_settings(STATICFILES_STORAGE="openedx.core.storage.DevelopmentStorage")
+ @override_settings(
+ STORAGES={
+ 'staticfiles': {
+ 'BACKEND': 'openedx.core.storage.DevelopmentStorage'
+ }
+ }
+ )
def test_asset_with_theme(self):
"""
Fetch theme asset when a theme is set.
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
index 466e1e278a..497665489c 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
@@ -1231,7 +1231,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
)
def test_profile_backend_with_default_hardcoded_backend(self):
""" In case of empty storages scenario uses the hardcoded backend."""
- del settings.DEFAULT_FILE_STORAGE
del settings.STORAGES
storage = get_profile_image_storage()
self.assertIsInstance(storage, FileSystemStorage)
diff --git a/openedx/core/djangoapps/user_authn/views/logout.py b/openedx/core/djangoapps/user_authn/views/logout.py
index 616b792b9f..083e8d4ccb 100644
--- a/openedx/core/djangoapps/user_authn/views/logout.py
+++ b/openedx/core/djangoapps/user_authn/views/logout.py
@@ -8,7 +8,6 @@ from urllib.parse import parse_qs, urlsplit, urlunsplit # pylint: disable=impor
import nh3
from django.conf import settings
from django.contrib.auth import logout
-from django.shortcuts import redirect
from django.utils.http import urlencode
from django.views.generic import TemplateView
from oauth2_provider.models import Application
@@ -47,7 +46,13 @@ class LogoutView(TemplateView):
If a redirect_url is specified in the querystring for this request, and the value is a safe
url for redirect, the view will redirect to this page after rendering the template.
If it is not specified, we will use the default target url.
+ Redirect to tpa_logout_url if TPA_AUTOMATIC_LOGOUT_ENABLED is set to True and if
+ tpa_logout_url is configured.
"""
+
+ if getattr(settings, 'TPA_AUTOMATIC_LOGOUT_ENABLED', False) and self.tpa_logout_url:
+ return self.tpa_logout_url
+
target_url = self.request.GET.get('redirect_url') or self.request.GET.get('next')
# Some third party apps do not build URLs correctly and send next query param without URL-encoding, resulting
@@ -85,16 +90,6 @@ class LogoutView(TemplateView):
mark_user_change_as_expected(None)
- # Redirect to tpa_logout_url if TPA_AUTOMATIC_LOGOUT_ENABLED is set to True and if
- # tpa_logout_url is configured.
- #
- # NOTE: This step skips rendering logout.html, which is used to log the user out from the
- # different IDAs. To ensure the user is logged out of all the IDAs be sure to redirect
- # back to
/logout after logging out of the TPA.
- if getattr(settings, 'TPA_AUTOMATIC_LOGOUT_ENABLED', False):
- if self.tpa_logout_url:
- return redirect(self.tpa_logout_url)
-
return response
def _build_logout_url(self, url):
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_logout.py b/openedx/core/djangoapps/user_authn/views/tests/test_logout.py
index c59969c2d0..77c21c86e1 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_logout.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_logout.py
@@ -211,8 +211,10 @@ class LogoutTests(TestCase):
mock_idp_logout_url.return_value = idp_logout_url
self._authenticate_with_oauth(client)
response = self.client.get(reverse('logout'))
- assert response.status_code == 302
- assert response.url == idp_logout_url
+ expected = {
+ 'target': idp_logout_url,
+ }
+ self.assertDictContainsSubset(expected, response.context_data)
@mock.patch('django.conf.settings.TPA_AUTOMATIC_LOGOUT_ENABLED', True)
def test_no_automatic_tpa_logout_without_logout_url(self):
diff --git a/openedx/core/storage.py b/openedx/core/storage.py
index 9e7e52d94c..5dd0873d27 100644
--- a/openedx/core/storage.py
+++ b/openedx/core/storage.py
@@ -54,7 +54,7 @@ class ProductionMixin(
We use this version on production.
"""
def __init__(self, *args, **kwargs):
- kwargs.update(settings.STATICFILES_STORAGE_KWARGS.get(settings.STATICFILES_STORAGE, {}))
+ kwargs.update(settings.STATICFILES_STORAGE_KWARGS.get(settings.STORAGES['staticfiles']['BACKEND'], {}))
super().__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
@@ -112,5 +112,5 @@ def get_storage(storage_class=None, **kwargs):
the storage implementation makes http requests when instantiated, for
example.
"""
- storage_cls = import_string(storage_class or settings.DEFAULT_FILE_STORAGE)
+ storage_cls = import_string(storage_class or settings.STORAGES["default"]["BACKEND"])
return storage_cls(**kwargs)
diff --git a/openedx/core/tests/test_storage.py b/openedx/core/tests/test_storage.py
new file mode 100644
index 0000000000..1a15786ba5
--- /dev/null
+++ b/openedx/core/tests/test_storage.py
@@ -0,0 +1,54 @@
+"""
+Tests for the get_storage utility function.
+"""
+
+from django.test import TestCase, override_settings
+from django.core.files.storage import FileSystemStorage
+
+from openedx.core.storage import get_storage
+
+
+class TestGetStorage(TestCase):
+ """
+ Tests of the get_storage function
+ """
+
+ def setUp(self):
+ super().setUp()
+ get_storage.cache_clear()
+
+ def tearDown(self):
+ get_storage.cache_clear()
+
+ @override_settings(
+ STORAGES={
+ 'default': {
+ 'BACKEND': 'django.core.files.storage.FileSystemStorage'
+ }
+ }
+ )
+ def test_get_storage_returns_default_storage_when_no_class_specified(self):
+ """Test that get_storage returns the default storage when no storage_class is provided."""
+ storage = get_storage()
+ self.assertIsInstance(storage, FileSystemStorage)
+
+ def test_get_storage_returns_custom_storage_when_class_specified(self):
+ """Test that get_storage returns the specified storage class."""
+ storage_class = 'django.core.files.storage.FileSystemStorage'
+ storage = get_storage(storage_class=storage_class)
+ self.assertIsInstance(storage, FileSystemStorage)
+
+ def test_get_storage_caching_behavior(self):
+ """Test that get_storage caches instances with identical arguments."""
+ storage_class = 'django.core.files.storage.FileSystemStorage'
+ kwargs = {'location': '/test/path'}
+ # First Call
+ storage1 = get_storage(storage_class=storage_class, **kwargs)
+ # Second Call
+ storage2 = get_storage(storage_class=storage_class, **kwargs)
+ self.assertIs(storage1, storage2)
+
+ def test_get_storage_handles_invalid_storage_class(self):
+ """Test that get_storage raises appropriate error for invalid storage class."""
+ with self.assertRaises(ImportError):
+ get_storage(storage_class='nonexistent.storage.InvalidStorage')
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index 0d35e1bd0a..772321fe52 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -47,7 +47,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.3.0
+edx-enterprise==6.3.2
# Date: 2023-07-26
# Our legacy Sass code is incompatible with anything except this ancient libsass version.
@@ -120,12 +120,6 @@ social-auth-app-django<=5.4.1
# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35126
elasticsearch==7.9.1
-# Date 2025-03-21
-# social-auth-core>4.5.4 breaks tests with authorization on LinkedIn API
-# Both of these constraints will be updated in a follow up PR under the following issue:
-# https://github.com/openedx/edx-platform/issues/36425
-social-auth-core==4.5.4
-
# Date 2025-05-09
# lxml and xmlsec need to be constrained because the latest version builds against a newer
# version of libxml2 than what we're running with. This leads to a version mismatch error
diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt
index 193af3cec3..4b5fd28677 100644
--- a/requirements/edx-sandbox/base.txt
+++ b/requirements/edx-sandbox/base.txt
@@ -4,7 +4,7 @@
#
# make upgrade
#
-cffi==1.17.1
+cffi==2.0.0
# via cryptography
chem==2.0.0
# via -r requirements/edx-sandbox/base.in
@@ -14,7 +14,7 @@ codejail-includes==2.0.0
# via -r requirements/edx-sandbox/base.in
contourpy==1.3.3
# via matplotlib
-cryptography==45.0.6
+cryptography==45.0.7
# via -r requirements/edx-sandbox/base.in
cycler==0.12.1
# via matplotlib
@@ -36,7 +36,7 @@ markupsafe==3.0.2
# via
# chem
# openedx-calc
-matplotlib==3.10.5
+matplotlib==3.10.6
# via -r requirements/edx-sandbox/base.in
mpmath==1.3.0
# via sympy
@@ -72,7 +72,7 @@ python-dateutil==2.9.0.post0
# via matplotlib
random2==1.0.2
# via -r requirements/edx-sandbox/base.in
-regex==2025.7.34
+regex==2025.9.1
# via nltk
scipy==1.16.1
# via
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index bd4b2e59a9..4aefa1daea 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -68,14 +68,14 @@ bleach[css]==6.2.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/kernel.in
-boto3==1.40.20
+boto3==1.40.26
# via
# -r requirements/edx/kernel.in
# django-ses
# fs-s3fs
# ora2
# snowflake-connector-python
-botocore==1.40.20
+botocore==1.40.26
# via
# -r requirements/edx/kernel.in
# boto3
@@ -146,7 +146,7 @@ codejail-includes==2.0.0
# via -r requirements/edx/kernel.in
crowdsourcehinter-xblock==0.8
# via -r requirements/edx/bundled.in
-cryptography==45.0.6
+cryptography==45.0.7
# via
# -r requirements/edx/kernel.in
# django-fernet-fields-v2
@@ -167,7 +167,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-django==4.2.23
+django==4.2.24
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
@@ -258,7 +258,7 @@ django-config-models==2.9.0
# edx-name-affirmation
# enterprise-integrated-channels
# lti-consumer-xblock
-django-cors-headers==4.7.0
+django-cors-headers==4.8.0
# via -r requirements/edx/kernel.in
django-countries==7.6.1
# via
@@ -316,7 +316,7 @@ django-mptt==0.18.0
# openedx-django-wiki
django-multi-email-field==0.8.0
# via edx-enterprise
-django-mysql==4.17.0
+django-mysql==4.18.0
# via -r requirements/edx/kernel.in
django-oauth-toolkit==1.7.1
# via
@@ -397,7 +397,7 @@ djangorestframework==3.16.1
# super-csv
djangorestframework-xml==2.0.0
# via edx-enterprise
-dnspython==2.7.0
+dnspython==2.8.0
# via pymongo
done-xblock==2.5.0
# via -r requirements/edx/bundled.in
@@ -474,7 +474,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
-edx-enterprise==6.3.0
+edx-enterprise==6.3.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
@@ -523,7 +523,7 @@ edx-rest-api-client==6.2.0
# edx-enterprise
# edx-proctoring
# enterprise-integrated-channels
-edx-search==4.1.3
+edx-search==4.3.0
# via
# -r requirements/edx/kernel.in
# openedx-forum
@@ -566,7 +566,7 @@ enmerkar==0.7.1
# via enmerkar-underscore
enmerkar-underscore==2.4.0
# via -r requirements/edx/kernel.in
-enterprise-integrated-channels==0.1.14
+enterprise-integrated-channels==0.1.15
# via -r requirements/edx/bundled.in
event-tracking==3.3.0
# via
@@ -618,7 +618,7 @@ google-cloud-core==2.4.3
# google-cloud-storage
google-cloud-firestore==2.21.0
# via firebase-admin
-google-cloud-storage==3.3.0
+google-cloud-storage==3.3.1
# via firebase-admin
google-crc32c==1.7.1
# via
@@ -702,7 +702,7 @@ jsonschema==4.25.1
# via
# drf-spectacular
# optimizely-sdk
-jsonschema-specifications==2025.4.1
+jsonschema-specifications==2025.9.1
# via jsonschema
jwcrypto==1.5.6
# via
@@ -747,7 +747,7 @@ mako==1.3.10
# lti-consumer-xblock
# xblock
# xblock-utils
-markdown==3.8.2
+markdown==3.9
# via
# -r requirements/edx/kernel.in
# openedx-django-wiki
@@ -770,7 +770,7 @@ mongoengine==0.29.1
# via -r requirements/edx/kernel.in
monotonic==1.6
# via analytics-python
-more-itertools==10.7.0
+more-itertools==10.8.0
# via cssutils
mpmath==1.3.0
# via sympy
@@ -843,7 +843,7 @@ openedx-filters==2.1.0
# -r requirements/edx/kernel.in
# lti-consumer-xblock
# ora2
-openedx-forum==0.3.4
+openedx-forum==0.3.6
# via -r requirements/edx/kernel.in
openedx-learning==0.27.1
# via
@@ -1039,7 +1039,7 @@ referencing==0.36.2
# via
# jsonschema
# jsonschema-specifications
-regex==2025.7.34
+regex==2025.9.1
# via nltk
requests==2.32.5
# via
@@ -1127,16 +1127,15 @@ slumber==0.7.1
# enterprise-integrated-channels
sniffio==1.3.1
# via anyio
-snowflake-connector-python==3.17.2
+snowflake-connector-python==3.17.3
# via edx-enterprise
social-auth-app-django==5.4.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
# edx-auth-backends
-social-auth-core==4.5.4
+social-auth-core==4.7.0
# via
- # -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
# edx-auth-backends
# social-auth-app-django
diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt
index 8d7141c6ff..57a6416926 100644
--- a/requirements/edx/coverage.txt
+++ b/requirements/edx/coverage.txt
@@ -6,7 +6,7 @@
#
chardet==5.2.0
# via diff-cover
-coverage==7.10.5
+coverage==7.10.6
# via -r requirements/edx/coverage.in
diff-cover==9.6.0
# via -r requirements/edx/coverage.in
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index a7b2c77d32..2c3b4a8cd1 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -136,7 +136,7 @@ boto==2.49.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-boto3==1.40.20
+boto3==1.40.26
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -144,7 +144,7 @@ boto3==1.40.20
# fs-s3fs
# ora2
# snowflake-connector-python
-botocore==1.40.20
+botocore==1.40.26
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -277,7 +277,7 @@ colorama==0.4.6
# via
# -r requirements/edx/testing.txt
# tox
-coverage[toml]==7.10.5
+coverage[toml]==7.10.6
# via
# -r requirements/edx/testing.txt
# pytest-cov
@@ -285,7 +285,7 @@ crowdsourcehinter-xblock==0.8
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-cryptography==45.0.6
+cryptography==45.0.7
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -331,7 +331,7 @@ distlib==0.4.0
# via
# -r requirements/edx/testing.txt
# virtualenv
-django==4.2.23
+django==4.2.24
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -440,7 +440,7 @@ django-config-models==2.9.0
# edx-name-affirmation
# enterprise-integrated-channels
# lti-consumer-xblock
-django-cors-headers==4.7.0
+django-cors-headers==4.8.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -520,7 +520,7 @@ django-multi-email-field==0.8.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-enterprise
-django-mysql==4.17.0
+django-mysql==4.18.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -636,7 +636,7 @@ djangorestframework-xml==2.0.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-enterprise
-dnspython==2.7.0
+dnspython==2.8.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -748,7 +748,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
-edx-enterprise==6.3.0
+edx-enterprise==6.3.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -817,7 +817,7 @@ edx-rest-api-client==6.2.0
# edx-enterprise
# edx-proctoring
# enterprise-integrated-channels
-edx-search==4.1.3
+edx-search==4.3.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -877,7 +877,7 @@ enmerkar-underscore==2.4.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-enterprise-integrated-channels==0.1.14
+enterprise-integrated-channels==0.1.15
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -985,7 +985,7 @@ google-cloud-firestore==2.21.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# firebase-admin
-google-cloud-storage==3.3.0
+google-cloud-storage==3.3.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1007,7 +1007,7 @@ googleapis-common-protos==1.70.0
# -r requirements/edx/testing.txt
# google-api-core
# grpcio-status
-grimp==3.9
+grimp==3.11
# via
# -r requirements/edx/testing.txt
# import-linter
@@ -1138,7 +1138,6 @@ joblib==1.5.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
- # grimp
# nltk
jsondiff==2.2.1
# via
@@ -1163,7 +1162,7 @@ jsonschema==4.25.1
# drf-spectacular
# optimizely-sdk
# sphinxcontrib-openapi
-jsonschema-specifications==2025.4.1
+jsonschema-specifications==2025.9.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1237,7 +1236,7 @@ mako==1.3.10
# lti-consumer-xblock
# xblock
# xblock-utils
-markdown==3.8.2
+markdown==3.9
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1282,7 +1281,7 @@ monotonic==1.6
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# analytics-python
-more-itertools==10.7.0
+more-itertools==10.8.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1398,7 +1397,7 @@ openedx-filters==2.1.0
# -r requirements/edx/testing.txt
# lti-consumer-xblock
# ora2
-openedx-forum==0.3.4
+openedx-forum==0.3.6
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1706,7 +1705,7 @@ pytest==8.2.0
# pytest-xdist
pytest-attrib==0.1.3
# via -r requirements/edx/testing.txt
-pytest-cov==6.2.1
+pytest-cov==6.3.0
# via -r requirements/edx/testing.txt
pytest-django==4.11.1
# via -r requirements/edx/testing.txt
@@ -1813,7 +1812,7 @@ referencing==0.36.2
# -r requirements/edx/testing.txt
# jsonschema
# jsonschema-specifications
-regex==2025.7.34
+regex==2025.9.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1955,7 +1954,7 @@ snowballstemmer==3.0.1
# via
# -r requirements/edx/doc.txt
# sphinx
-snowflake-connector-python==3.17.2
+snowflake-connector-python==3.17.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1966,9 +1965,8 @@ social-auth-app-django==5.4.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-auth-backends
-social-auth-core==4.5.4
+social-auth-core==4.7.0
# via
- # -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-auth-backends
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index 8b00a29713..9cbb723bc3 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -103,14 +103,14 @@ bleach[css]==6.2.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
-boto3==1.40.20
+boto3==1.40.26
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
# snowflake-connector-python
-botocore==1.40.20
+botocore==1.40.26
# via
# -r requirements/edx/base.txt
# boto3
@@ -200,7 +200,7 @@ codejail-includes==2.0.0
# via -r requirements/edx/base.txt
crowdsourcehinter-xblock==0.8
# via -r requirements/edx/base.txt
-cryptography==45.0.6
+cryptography==45.0.7
# via
# -r requirements/edx/base.txt
# django-fernet-fields-v2
@@ -225,7 +225,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-django==4.2.23
+django==4.2.24
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -322,7 +322,7 @@ django-config-models==2.9.0
# edx-name-affirmation
# enterprise-integrated-channels
# lti-consumer-xblock
-django-cors-headers==4.7.0
+django-cors-headers==4.8.0
# via -r requirements/edx/base.txt
django-countries==7.6.1
# via
@@ -385,7 +385,7 @@ django-multi-email-field==0.8.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
-django-mysql==4.17.0
+django-mysql==4.18.0
# via -r requirements/edx/base.txt
django-oauth-toolkit==1.7.1
# via
@@ -471,7 +471,7 @@ djangorestframework-xml==2.0.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
-dnspython==2.7.0
+dnspython==2.8.0
# via
# -r requirements/edx/base.txt
# pymongo
@@ -558,7 +558,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
-edx-enterprise==6.3.0
+edx-enterprise==6.3.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -608,7 +608,7 @@ edx-rest-api-client==6.2.0
# edx-enterprise
# edx-proctoring
# enterprise-integrated-channels
-edx-search==4.1.3
+edx-search==4.3.0
# via
# -r requirements/edx/base.txt
# openedx-forum
@@ -655,7 +655,7 @@ enmerkar==0.7.1
# enmerkar-underscore
enmerkar-underscore==2.4.0
# via -r requirements/edx/base.txt
-enterprise-integrated-channels==0.1.14
+enterprise-integrated-channels==0.1.15
# via -r requirements/edx/base.txt
event-tracking==3.3.0
# via
@@ -725,7 +725,7 @@ google-cloud-firestore==2.21.0
# via
# -r requirements/edx/base.txt
# firebase-admin
-google-cloud-storage==3.3.0
+google-cloud-storage==3.3.1
# via
# -r requirements/edx/base.txt
# firebase-admin
@@ -849,7 +849,7 @@ jsonschema==4.25.1
# drf-spectacular
# optimizely-sdk
# sphinxcontrib-openapi
-jsonschema-specifications==2025.4.1
+jsonschema-specifications==2025.9.1
# via
# -r requirements/edx/base.txt
# jsonschema
@@ -904,7 +904,7 @@ mako==1.3.10
# lti-consumer-xblock
# xblock
# xblock-utils
-markdown==3.8.2
+markdown==3.9
# via
# -r requirements/edx/base.txt
# openedx-django-wiki
@@ -934,7 +934,7 @@ monotonic==1.6
# via
# -r requirements/edx/base.txt
# analytics-python
-more-itertools==10.7.0
+more-itertools==10.8.0
# via
# -r requirements/edx/base.txt
# cssutils
@@ -1019,7 +1019,7 @@ openedx-filters==2.1.0
# -r requirements/edx/base.txt
# lti-consumer-xblock
# ora2
-openedx-forum==0.3.4
+openedx-forum==0.3.6
# via -r requirements/edx/base.txt
openedx-learning==0.27.1
# via
@@ -1268,7 +1268,7 @@ referencing==0.36.2
# -r requirements/edx/base.txt
# jsonschema
# jsonschema-specifications
-regex==2025.7.34
+regex==2025.9.1
# via
# -r requirements/edx/base.txt
# nltk
@@ -1380,7 +1380,7 @@ sniffio==1.3.1
# anyio
snowballstemmer==3.0.1
# via sphinx
-snowflake-connector-python==3.17.2
+snowflake-connector-python==3.17.3
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1389,9 +1389,8 @@ social-auth-app-django==5.4.1
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-auth-backends
-social-auth-core==4.5.4
+social-auth-core==4.7.0
# via
- # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-auth-backends
# social-auth-app-django
diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt
index 87703c82a8..e5fe1dc79f 100644
--- a/requirements/edx/semgrep.txt
+++ b/requirements/edx/semgrep.txt
@@ -49,7 +49,7 @@ importlib-metadata==7.1.0
# via opentelemetry-api
jsonschema==4.25.1
# via semgrep
-jsonschema-specifications==2025.4.1
+jsonschema-specifications==2025.9.1
# via jsonschema
markdown-it-py==4.0.0
# via rich
@@ -113,7 +113,7 @@ ruamel-yaml==0.18.15
# via semgrep
ruamel-yaml-clib==0.2.12
# via ruamel-yaml
-semgrep==1.134.0
+semgrep==1.135.0
# via -r requirements/edx/semgrep.in
tomli==2.0.2
# via semgrep
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index e2b4402238..6a53c3736f 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -100,14 +100,14 @@ bleach[css]==6.2.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
-boto3==1.40.20
+boto3==1.40.26
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
# snowflake-connector-python
-botocore==1.40.20
+botocore==1.40.26
# via
# -r requirements/edx/base.txt
# boto3
@@ -210,13 +210,13 @@ codejail-includes==2.0.0
# via -r requirements/edx/base.txt
colorama==0.4.6
# via tox
-coverage[toml]==7.10.5
+coverage[toml]==7.10.6
# via
# -r requirements/edx/coverage.txt
# pytest-cov
crowdsourcehinter-xblock==0.8
# via -r requirements/edx/base.txt
-cryptography==45.0.6
+cryptography==45.0.7
# via
# -r requirements/edx/base.txt
# django-fernet-fields-v2
@@ -251,7 +251,7 @@ dill==0.4.0
# via pylint
distlib==0.4.0
# via virtualenv
-django==4.2.23
+django==4.2.24
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -348,7 +348,7 @@ django-config-models==2.9.0
# edx-name-affirmation
# enterprise-integrated-channels
# lti-consumer-xblock
-django-cors-headers==4.7.0
+django-cors-headers==4.8.0
# via -r requirements/edx/base.txt
django-countries==7.6.1
# via
@@ -411,7 +411,7 @@ django-multi-email-field==0.8.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
-django-mysql==4.17.0
+django-mysql==4.18.0
# via -r requirements/edx/base.txt
django-oauth-toolkit==1.7.1
# via
@@ -497,7 +497,7 @@ djangorestframework-xml==2.0.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
-dnspython==2.7.0
+dnspython==2.8.0
# via
# -r requirements/edx/base.txt
# pymongo
@@ -579,7 +579,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
-edx-enterprise==6.3.0
+edx-enterprise==6.3.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -631,7 +631,7 @@ edx-rest-api-client==6.2.0
# edx-enterprise
# edx-proctoring
# enterprise-integrated-channels
-edx-search==4.1.3
+edx-search==4.3.0
# via
# -r requirements/edx/base.txt
# openedx-forum
@@ -678,7 +678,7 @@ enmerkar==0.7.1
# enmerkar-underscore
enmerkar-underscore==2.4.0
# via -r requirements/edx/base.txt
-enterprise-integrated-channels==0.1.14
+enterprise-integrated-channels==0.1.15
# via -r requirements/edx/base.txt
event-tracking==3.3.0
# via
@@ -756,7 +756,7 @@ google-cloud-firestore==2.21.0
# via
# -r requirements/edx/base.txt
# firebase-admin
-google-cloud-storage==3.3.0
+google-cloud-storage==3.3.1
# via
# -r requirements/edx/base.txt
# firebase-admin
@@ -774,7 +774,7 @@ googleapis-common-protos==1.70.0
# -r requirements/edx/base.txt
# google-api-core
# grpcio-status
-grimp==3.9
+grimp==3.11
# via import-linter
grpcio==1.74.0
# via
@@ -870,7 +870,6 @@ jmespath==1.0.1
joblib==1.5.2
# via
# -r requirements/edx/base.txt
- # grimp
# nltk
jsondiff==2.2.1
# via
@@ -891,7 +890,7 @@ jsonschema==4.25.1
# -r requirements/edx/base.txt
# drf-spectacular
# optimizely-sdk
-jsonschema-specifications==2025.4.1
+jsonschema-specifications==2025.9.1
# via
# -r requirements/edx/base.txt
# jsonschema
@@ -947,7 +946,7 @@ mako==1.3.10
# lti-consumer-xblock
# xblock
# xblock-utils
-markdown==3.8.2
+markdown==3.9
# via
# -r requirements/edx/base.txt
# openedx-django-wiki
@@ -980,7 +979,7 @@ monotonic==1.6
# via
# -r requirements/edx/base.txt
# analytics-python
-more-itertools==10.7.0
+more-itertools==10.8.0
# via
# -r requirements/edx/base.txt
# cssutils
@@ -1065,7 +1064,7 @@ openedx-filters==2.1.0
# -r requirements/edx/base.txt
# lti-consumer-xblock
# ora2
-openedx-forum==0.3.4
+openedx-forum==0.3.6
# via -r requirements/edx/base.txt
openedx-learning==0.27.1
# via
@@ -1292,7 +1291,7 @@ pytest==8.2.0
# pytest-xdist
pytest-attrib==0.1.3
# via -r requirements/edx/testing.in
-pytest-cov==6.2.1
+pytest-cov==6.3.0
# via -r requirements/edx/testing.in
pytest-django==4.11.1
# via -r requirements/edx/testing.in
@@ -1378,7 +1377,7 @@ referencing==0.36.2
# -r requirements/edx/base.txt
# jsonschema
# jsonschema-specifications
-regex==2025.7.34
+regex==2025.9.1
# via
# -r requirements/edx/base.txt
# nltk
@@ -1487,7 +1486,7 @@ sniffio==1.3.1
# via
# -r requirements/edx/base.txt
# anyio
-snowflake-connector-python==3.17.2
+snowflake-connector-python==3.17.3
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1496,9 +1495,8 @@ social-auth-app-django==5.4.1
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-auth-backends
-social-auth-core==4.5.4
+social-auth-core==4.7.0
# via
- # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-auth-backends
# social-auth-app-django
diff --git a/scripts/structures_pruning/requirements/base.txt b/scripts/structures_pruning/requirements/base.txt
index 9a0bf10bf3..fc3b43a82b 100644
--- a/scripts/structures_pruning/requirements/base.txt
+++ b/scripts/structures_pruning/requirements/base.txt
@@ -10,7 +10,7 @@ click==8.2.1
# click-log
click-log==0.4.0
# via -r scripts/structures_pruning/requirements/base.in
-dnspython==2.7.0
+dnspython==2.8.0
# via pymongo
edx-opaque-keys==3.0.0
# via -r scripts/structures_pruning/requirements/base.in
diff --git a/scripts/structures_pruning/requirements/testing.txt b/scripts/structures_pruning/requirements/testing.txt
index 5e0e3bf6a8..83d3f5746e 100644
--- a/scripts/structures_pruning/requirements/testing.txt
+++ b/scripts/structures_pruning/requirements/testing.txt
@@ -12,7 +12,7 @@ click-log==0.4.0
# via -r scripts/structures_pruning/requirements/base.txt
ddt==1.7.2
# via -r scripts/structures_pruning/requirements/testing.in
-dnspython==2.7.0
+dnspython==2.8.0
# via
# -r scripts/structures_pruning/requirements/base.txt
# pymongo
@@ -30,7 +30,7 @@ pymongo==4.4.0
# via
# -r scripts/structures_pruning/requirements/base.txt
# edx-opaque-keys
-pytest==8.4.1
+pytest==8.4.2
# via -r scripts/structures_pruning/requirements/testing.in
stevedore==5.5.0
# via
diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt
index e141223acc..8b576aead5 100644
--- a/scripts/user_retirement/requirements/base.txt
+++ b/scripts/user_retirement/requirements/base.txt
@@ -10,9 +10,9 @@ attrs==25.3.0
# via zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.in
-boto3==1.40.20
+boto3==1.40.26
# via -r scripts/user_retirement/requirements/base.in
-botocore==1.40.20
+botocore==1.40.26
# via
# boto3
# s3transfer
@@ -20,7 +20,7 @@ cachetools==5.5.2
# via google-auth
certifi==2025.8.3
# via requests
-cffi==1.17.1
+cffi==2.0.0
# via
# cryptography
# pynacl
@@ -30,9 +30,9 @@ click==8.2.1
# via
# -r scripts/user_retirement/requirements/base.in
# edx-django-utils
-cryptography==45.0.6
+cryptography==45.0.7
# via pyjwt
-django==4.2.23
+django==4.2.24
# via
# -c scripts/user_retirement/requirements/../../../requirements/constraints.txt
# django-crum
@@ -48,7 +48,7 @@ edx-rest-api-client==6.2.0
# via -r scripts/user_retirement/requirements/base.in
google-api-core==2.25.1
# via google-api-python-client
-google-api-python-client==2.179.0
+google-api-python-client==2.181.0
# via -r scripts/user_retirement/requirements/base.in
google-auth==2.40.3
# via
@@ -59,7 +59,7 @@ google-auth-httplib2==0.2.0
# via google-api-python-client
googleapis-common-protos==1.70.0
# via google-api-core
-httplib2==0.22.0
+httplib2==0.30.0
# via
# google-api-python-client
# google-auth-httplib2
@@ -77,7 +77,7 @@ lxml==5.3.2
# via
# -c scripts/user_retirement/requirements/../../../requirements/constraints.txt
# zeep
-more-itertools==10.7.0
+more-itertools==10.8.0
# via simple-salesforce
platformdirs==4.4.0
# via zeep
diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt
index 0050141b3a..f5b98650f3 100644
--- a/scripts/user_retirement/requirements/testing.txt
+++ b/scripts/user_retirement/requirements/testing.txt
@@ -14,11 +14,11 @@ attrs==25.3.0
# zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.txt
-boto3==1.40.20
+boto3==1.40.26
# via
# -r scripts/user_retirement/requirements/base.txt
# moto
-botocore==1.40.20
+botocore==1.40.26
# via
# -r scripts/user_retirement/requirements/base.txt
# boto3
@@ -32,7 +32,7 @@ certifi==2025.8.3
# via
# -r scripts/user_retirement/requirements/base.txt
# requests
-cffi==1.17.1
+cffi==2.0.0
# via
# -r scripts/user_retirement/requirements/base.txt
# cryptography
@@ -45,14 +45,14 @@ click==8.2.1
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
-cryptography==45.0.6
+cryptography==45.0.7
# via
# -r scripts/user_retirement/requirements/base.txt
# moto
# pyjwt
ddt==1.7.2
# via -r scripts/user_retirement/requirements/testing.in
-django==4.2.23
+django==4.2.24
# via
# -r scripts/user_retirement/requirements/base.txt
# django-crum
@@ -76,7 +76,7 @@ google-api-core==2.25.1
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-python-client
-google-api-python-client==2.179.0
+google-api-python-client==2.181.0
# via -r scripts/user_retirement/requirements/base.txt
google-auth==2.40.3
# via
@@ -92,7 +92,7 @@ googleapis-common-protos==1.70.0
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-core
-httplib2==0.22.0
+httplib2==0.30.0
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-python-client
@@ -126,11 +126,11 @@ markupsafe==3.0.2
# werkzeug
mock==5.2.0
# via -r scripts/user_retirement/requirements/testing.in
-more-itertools==10.7.0
+more-itertools==10.8.0
# via
# -r scripts/user_retirement/requirements/base.txt
# simple-salesforce
-moto==5.1.11
+moto==5.1.12
# via -r scripts/user_retirement/requirements/testing.in
packaging==25.0
# via pytest
@@ -182,7 +182,7 @@ pyparsing==3.2.3
# via
# -r scripts/user_retirement/requirements/base.txt
# httplib2
-pytest==8.4.1
+pytest==8.4.2
# via -r scripts/user_retirement/requirements/testing.in
python-dateutil==2.9.0.post0
# via
@@ -268,7 +268,7 @@ urllib3==2.5.0
# responses
werkzeug==3.1.3
# via moto
-xmltodict==0.14.2
+xmltodict==0.15.1
# via moto
zeep==4.3.1
# via
diff --git a/xmodule/modulestore/xml_exporter.py b/xmodule/modulestore/xml_exporter.py
index 84986e2c15..eb6341defa 100644
--- a/xmodule/modulestore/xml_exporter.py
+++ b/xmodule/modulestore/xml_exporter.py
@@ -9,6 +9,7 @@ from abc import abstractmethod
from json import dumps
import lxml.etree
+from edx_django_utils.monitoring import set_custom_attribute
from fs.osfs import OSFS
from opaque_keys.edx.locator import CourseLocator, LibraryLocator
from xblock.fields import Reference, ReferenceList, ReferenceValueDict, Scope
@@ -207,6 +208,7 @@ class CourseExportManager(ExportManager):
def process_extra(self, root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs):
# Export the modulestore's asset metadata.
+ set_custom_attribute("export_asset_started", str(courselike))
asset_dir = root_courselike_dir + '/' + AssetMetadata.EXPORTED_ASSET_DIR + '/'
if not os.path.isdir(asset_dir):
os.makedirs(asset_dir)
@@ -220,6 +222,7 @@ class CourseExportManager(ExportManager):
lxml.etree.ElementTree(asset_root).write(asset_xml_file, encoding='utf-8')
# export the static assets
+ set_custom_attribute("export_static_assets_started", str(courselike))
policies_dir = export_fs.makedir('policies', recreate=True)
if self.contentstore:
self.contentstore.export_all_for_course(
@@ -248,24 +251,28 @@ class CourseExportManager(ExportManager):
course_image_file.write(course_image.data)
# export the static tabs
+ set_custom_attribute("export_tabs_started", str(courselike))
export_extra_content(
export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key,
'static_tab', 'tabs', '.html'
)
# export the custom tags
+ set_custom_attribute("export_custom_tags_started", str(courselike))
export_extra_content(
export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key,
'custom_tag_template', 'custom_tags'
)
# export the course updates
+ set_custom_attribute("export_course_updates_started", str(courselike))
export_extra_content(
export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key,
'course_info', 'info', '.html'
)
# export the 'about' data (e.g. overview, etc.)
+ set_custom_attribute("export_about_started", str(courselike))
export_extra_content(
export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key,
'about', 'about', '.html'
@@ -280,10 +287,12 @@ class CourseExportManager(ExportManager):
sort_keys=True, indent=4).encode('utf-8'))
# export all of the course metadata in policy.json
+ set_custom_attribute("export_policy_started", str(courselike))
with course_run_policy_dir.open('policy.json', 'wb') as course_policy:
policy = {'course/' + courselike.location.run: own_metadata(courselike)}
course_policy.write(dumps(policy, cls=EdxJSONEncoder, sort_keys=True, indent=4).encode('utf-8'))
+ set_custom_attribute("export_drafts_started", str(courselike))
_export_drafts(self.modulestore, self.courselike_key, export_fs, xml_centric_courselike_key)
courselike_key_str = str(self.courselike_key)