Skip to content

Commit 8a6be77

Browse files
committed
feat: Subsection(sequential) prerequisites lost during course export/import
* feat: adds prerequisite element to exported OLX. e.g. <sequential default_time_limit_minutes="0" display_name="Subsection 2" due="null" hide_after_due="false" show_correctness="always" start="2030-01-01T00:00:00Z"> <prerequisite> <required_sequential url_name="c129cef268e340c9a92e65c224ddcaf9"/> <min_score>80</min_score> <min_completion>90</min_completion> </prerequisite> <vertical url_name="07665b20eaab4a8b888ed6ab5de6188d"/> </sequential> This PR introduces support for subsection (sequential) prerequisites in the OLX course format. Course authors can now define prerequisite relationships between subsections with configurable completion criteria including minimum score and completion percentage requirements. Issue Link: #36995
1 parent 467bb32 commit 8a6be77

File tree

3 files changed

+80
-5
lines changed

3 files changed

+80
-5
lines changed

cms/djangoapps/contentstore/utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@
121121

122122
from .models import ComponentLink, ContainerLink
123123

124+
from openedx.core.lib.gating.services import GatingService
125+
124126
IMPORTABLE_FILE_TYPES = ('.tar.gz', '.zip')
125127
log = logging.getLogger(__name__)
126128

@@ -1324,7 +1326,8 @@ def load_services_for_studio(runtime, user):
13241326
"settings": SettingsService(),
13251327
"lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag),
13261328
"teams_configuration": TeamsConfigurationService(),
1327-
"library_tools": LegacyLibraryToolsService(modulestore(), user.id)
1329+
"library_tools": LegacyLibraryToolsService(modulestore(), user.id),
1330+
'gating': GatingService(),
13281331
}
13291332

13301333
runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access

openedx/core/lib/gating/services.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,16 @@ def is_gate_fulfilled(self, course_key, gating_content_key, user_id):
5454
Returns False otherwise
5555
"""
5656
return gating_api.is_gate_fulfilled(course_key, gating_content_key, user_id)
57+
58+
def get_required_prereq_metadata(self, course_key, gated_content_key):
59+
"""
60+
Returns the prerequisite information of the provided subsection
61+
62+
Arguments:
63+
course_key (str|CourseKey): The course key
64+
content_key (str|UsageKey): The content usage key
65+
66+
Returns:
67+
dict or None: The gating milestone dict or None
68+
"""
69+
return gating_api.get_required_content(course_key, gated_content_key)

xmodule/xml_block.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,18 @@
1212
from xblock.runtime import KvsFieldData
1313
from xmodule.modulestore import EdxJSONEncoder
1414
from xmodule.modulestore.inheritance import InheritanceKeyValueStore, own_metadata
15-
15+
from opaque_keys.edx.keys import UsageKey
1616
log = logging.getLogger(__name__)
17-
1817
# assume all XML files are persisted as utf-8.
1918
EDX_XML_PARSER = XMLParser(dtd_validation=False, load_dtd=False, remove_blank_text=True, encoding='utf-8')
2019

21-
2220
def name_to_pathname(name):
2321
"""
2422
Convert a location name for use in a path: replace ':' with '/'.
2523
This allows users of the xml format to organize content into directories
2624
"""
2725
return name.replace(':', '/')
2826

29-
3027
def is_pointer_tag(xml_obj):
3128
"""
3229
Check if xml_obj is a pointer tag: <blah url_name="something" />.
@@ -139,6 +136,65 @@ class XmlMixin:
139136

140137
metadata_to_export_to_policy = ('discussion_topics',)
141138

139+
def _extract_prereq_url_name(self, prereq_usage_key):
140+
"""
141+
Extract the url_name from a prerequisite usage key.
142+
"""
143+
try:
144+
if isinstance(prereq_usage_key, str):
145+
prereq_usage_key = UsageKey.from_string(prereq_usage_key)
146+
147+
# Get the block_id which should be the url_name
148+
return prereq_usage_key.block_id
149+
150+
except (ImportError, ValueError, AttributeError, TypeError) as e:
151+
log.warning("Could not extract url_name from prerequisite key %s: %s", prereq_usage_key, e)
152+
# Try to extract from string as fallback
153+
if isinstance(prereq_usage_key, str):
154+
# Assuming format like: block-v1:org+course+run+type@sequential+block@url_name
155+
if '@sequential+block@' in prereq_usage_key:
156+
parts = prereq_usage_key.split('@sequential+block@')
157+
if len(parts) > 1:
158+
return parts[1]
159+
return None
160+
161+
def _add_prerequisite_to_xml(self, xml_object):
162+
"""
163+
Add prerequisite information to sequential XML for export.
164+
"""
165+
if self.category != 'sequential':
166+
return
167+
gating_service = self.runtime.service(self, 'gating')
168+
if not gating_service:
169+
return
170+
prereq_info = gating_service.get_required_prereq_metadata(
171+
self.location.course_key,
172+
self.location
173+
)
174+
if not prereq_info or not prereq_info[0]:
175+
return
176+
prereq_id, min_score, min_completion = prereq_info
177+
prereq_url_name = self._extract_prereq_url_name(prereq_id)
178+
if not prereq_url_name:
179+
log.warning("Could not extract prerequisite url_name from %s", prereq_id)
180+
return
181+
182+
# Create prerequisite element
183+
prereq_element = etree.Element('prerequisite')
184+
required_seq_element = etree.Element('required_sequential')
185+
required_seq_element.set('url_name', prereq_url_name)
186+
prereq_element.append(required_seq_element)
187+
if min_score is not None and int(min_score) > 0:
188+
min_score_element = etree.Element('min_score')
189+
min_score_element.text = str(min_score)
190+
prereq_element.append(min_score_element)
191+
if min_completion is not None and int(min_completion) > 0:
192+
min_completion_element = etree.Element('min_completion')
193+
min_completion_element.text = str(min_completion)
194+
prereq_element.append(min_completion_element)
195+
# Insert at the beginning
196+
xml_object.insert(0, prereq_element)
197+
142198
@staticmethod
143199
def _get_metadata_from_xml(xml_object, remove=True):
144200
"""
@@ -442,6 +498,9 @@ def add_xml_to_node(self, node):
442498
if xml_object is None:
443499
return
444500

501+
if self.category == 'sequential':
502+
self._add_prerequisite_to_xml(xml_object) # Uncomment this later
503+
445504
for aside in self.runtime.get_asides(self):
446505
if aside.needs_serialization():
447506
aside_node = etree.Element("unknown_root", nsmap=XML_NAMESPACES)

0 commit comments

Comments
 (0)