Skip to content

Commit d43f4b4

Browse files
committed
feat: Unit Prerequisites Lost During Course Export/Import
This commit 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 d43f4b4

File tree

3 files changed

+95
-1
lines changed

3 files changed

+95
-1
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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
from xblock.runtime import KvsFieldData
1313
from xmodule.modulestore import EdxJSONEncoder
1414
from xmodule.modulestore.inheritance import InheritanceKeyValueStore, own_metadata
15+
from opaque_keys.edx.keys import UsageKey
1516

1617
log = logging.getLogger(__name__)
1718

1819
# assume all XML files are persisted as utf-8.
1920
EDX_XML_PARSER = XMLParser(dtd_validation=False, load_dtd=False, remove_blank_text=True, encoding='utf-8')
2021

2122

23+
2224
def name_to_pathname(name):
2325
"""
2426
Convert a location name for use in a path: replace ':' with '/'.
@@ -139,6 +141,79 @@ class XmlMixin:
139141

140142
metadata_to_export_to_policy = ('discussion_topics',)
141143

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

520+
if self.category == 'sequential':
521+
self._add_prerequisite_to_xml(xml_object) # Uncomment this later
522+
445523
for aside in self.runtime.get_asides(self):
446524
if aside.needs_serialization():
447525
aside_node = etree.Element("unknown_root", nsmap=XML_NAMESPACES)

0 commit comments

Comments
 (0)