-
Notifications
You must be signed in to change notification settings - Fork 76
Initial integration of QTI and IMSCP import #468
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
Looks like the way I am doing the zipfile testing is not compatible with Windows, open file handles etc - will need to update for that. |
ricecooker/utils/SCORM_metadata.py
Outdated
| # Update the node with the general metadata | ||
| node.description = metadata_dict.get("description") or node.description | ||
| if metadata_dict.get("language"): | ||
| node.set_language(metadata_dict.get("language")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had two problems ithe these few lines of code:
language was en-US
metadata.dict was this one:
(Pdb) metadata_dict
{'title': 'Work with computers', 'description': 'This learning path will introduce you to the different parts and types of the computer and their functions. You will also learn the difference between operating systems and applications and their functions. Peripherals and portable storage devices will be discussed as well.', 'language': 'en-US', 'keyword': 'Default'}
It worked with these changes (I ignore if the keyword change will work with other scorm files)
if metadata_dict.get("language"):
node.set_language(metadata_dict.get("language").lower().split("-")[0])
keyword = metadata_dict.get("keyword")
if keyword:
node.tags = node.tags + [keyword,]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated - hopefully has a similar effect, add some additional validation to make sure it's a language we know.
| is_primary = True | ||
|
|
||
| def get_preset(self): | ||
| return self.preset or format_presets.IMSCP_ZIP |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is producing a server error in Studio https://learningequality.sentry.io/issues/4997106816/?project=1252819
|
I've been playing with the latest code from this PR:
|
jredrejo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left a detailed comment with the two issues found
f4eadef to
a994a18
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Solid initial integration of IMSCP import into ricecooker — the manifest parsing, SCORM metadata mapping, and pipeline handler structure are well-designed. Two blocking issues around license propagation and dynamic child processing in nested structures need fixing before merge.
CI passing. All tests pass across Python 3.9-3.13 on all platforms.
Blocking:
- License not propagated through nested topics — leaf
ContentNodes underTopicNodes getlicense=None(see inline) process_filesdynamic children loop doesn't recurse intoTopicNodechildren's descendants (see inline)assertused for runtime validation of untrusted XML input inimscp.py— disabled underpython -O(see inline)
Suggestions:
QTIZipFileadded tofiles.pybut no corresponding QTI handler or tests — consider deferring to a follow-up PR or adding a comment (see inline)_extract_lom_textcan return mixed types (string, list, or None) which could surprise callers (see inline)
ricecooker/classes/nodes.py
Outdated
| node = ContentNode( | ||
| source_id, | ||
| title, | ||
| self.license, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
blocking: self.license is None when this method is called recursively on a TopicNode (created at line 790). TopicNode inherits from TreeNode where license defaults to None. So leaf ContentNodes created under topic nodes will have license=None, which will fail ContentNode.validate() at runtime (line 924: "ContentNode must have a license").
Consider propagating the license explicitly, e.g. adding a license parameter to _build_children_from_metadata similar to how parent_files is propagated:
def _build_children_from_metadata(self, children_list, parent_files=None, license=None):
license = license or self.license
...
node = ContentNode(source_id, title, license, ...)
...
node._build_children_from_metadata(nested_children, parent_files=parent_files, license=license)
ricecooker/classes/nodes.py
Outdated
| for f in child.files: | ||
| if f.filename: | ||
| child_filenames.append(f.filename) | ||
| filenames.extend(child_filenames) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
blocking: This loop only processes direct children (self.children). For TopicNode children that contain their own leaf ContentNode grandchildren, those grandchildren are never validated and their filenames are never collected. With a structure like ContentNode -> TopicNode -> ContentNode(leaf), the leaf's files won't appear in filenames.
This needs to recurse into TopicNode children, e.g.:
def _collect_dynamic_filenames(self, node):
filenames = []
for child in node.children:
if isinstance(child, ContentNode) and not child._files_processed:
child._files_processed = True
child.validate()
for f in child.files:
if f.filename:
filenames.append(f.filename)
if isinstance(child, TopicNode):
filenames.extend(self._collect_dynamic_filenames(child))
return filenames
ricecooker/utils/imscp.py
Outdated
| text += child.text | ||
| if child.tail: | ||
| text += child.tail | ||
| assert text.strip(), "Title element has no title: {}".format( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
blocking: assert is disabled when Python runs with -O (optimized mode). Since this validates untrusted XML input from content packages, it should use a proper exception:
if not text.strip():
raise ManifestParseError(
"Title element has no title: {}".format(
etree.tostring(title_elem, pretty_print=True)
)
)| return self.preset or format_presets.IMSCP_ZIP | ||
|
|
||
|
|
||
| class QTIZipFile(DownloadFile): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: QTIZipFile is added here but there's no corresponding QTIConversionHandler or QTIMetadataExtractor in the pipeline, and no tests for QTI handling. The PR title mentions "QTI and IMSCP" but only IMSCP is implemented. Consider either removing QTIZipFile from this PR (deferring to a follow-up) or adding a code comment noting it's a placeholder for upcoming QTI work.
| return s.text | ||
| if len(strings) == 1: | ||
| return strings[0].text | ||
| return [s.text for s in strings] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: _extract_lom_text returns a str (single string match), list[str] (multiple strings), or None — three different types. Callers in _collect_field pass the result through without normalization, so downstream code (e.g. metadata_dict_to_content_node_fields) needs to handle all three. This works for fields like keyword where _normalize_keywords handles both, but for title or description a list would be unexpected. Consider always returning a string for single-valued fields, or documenting the contract clearly.
Add utilities to map SCORM metadata fields (from IMS Content Packages) to Kolibri content node fields: title, description, language, tags, learning_activities, resource_types, and learner_needs. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add ricecooker/utils/imscp.py with functions for parsing imsmanifest.xml from IMSCP zip files. Includes recursive organization tree building, resource linking, external metadata resolution, encoding fallback via chardet, and single-child topic chain flattening to reduce unnecessary nesting levels. Adds xmltodict dependency to setup.py. Co-Authored-By: Claude Opus 4.6 <[email protected]>
…tadata Extend ContentNode._process_uri() to build child TopicNode/ContentNode trees when the pipeline produces nested ContentNodeMetadata (e.g. from IMSCP manifests with organization hierarchies). Add children and tags fields to ContentNodeMetadata. Simplify IMSCPZipFile and QTIZipFile to plain DownloadFile subclasses with only a preset override, since all parsing now happens in the pipeline. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add IMSCPConversionHandler and IMSCPMetadataExtractor to the file pipeline, ordered before their HTML5 counterparts so IMSCP zips (detected by presence of imsmanifest.xml) are handled correctly. The metadata extractor uses parse_imscp_manifest() and metadata_dict_to_content_node_fields() to produce nested ContentNodeMetadata trees, which ContentNode._process_uri() then expands into TopicNode/ContentNode children. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Extend IMSCP manifest parsing to recognize QTI resources (imsqti_* prefix) alongside webcontent. QTI leaf items are mapped to exercise kind with QTI_ZIP preset, while webcontent items retain html5/IMSCP_ZIP. Mixed packages route to IMSCP_ZIP at the file level with per-child preset overrides via shallow copy. Also hardens XML parsing (safe parser, null guards), migrates child metadata from raw dicts to typed ContentNodeMetadata objects, and extracts shared test helpers. Co-Authored-By: Claude Opus 4.6 <[email protected]>
rtibblesbot
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Solid initial integration of IMSCP and QTI import into ricecooker. The architecture is well-considered — standalone manifest parsing, metadata mapping utilities, pipeline handler integration, and nested child node building all fit cleanly into the existing codebase patterns.
CI passing (linting). Test coverage is thorough across all new modules.
- suggestion (1): Consider handler ordering for edge cases (convert.py)
- suggestion (2): Potential
AttributeErrorwhenTreeNode._build_children_from_metadatais called on aTopicNodewith nofilesattribute default (nodes.py) - suggestion (3):
_apply_content_node_metadataunconditionally overwrites list fields from pipeline (nodes.py) - nitpick (1): Metadata field application list is duplicated between two methods (nodes.py)
- nitpick (2):
_recursive_updatecould be adictin-place update note (context.py)
| """ | ||
| if parent_files is None: | ||
| parent_files = list(self.files) | ||
| license = license or getattr(self, "license", None) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: license = license or getattr(self, "license", None) — if self.license is a falsy value (e.g. an empty string was somehow set), this would fall through to None. In practice this is likely fine since ContentNode.__init__ requires a license object, but worth noting that getattr with a default of None combined with or means the fallback is only used when the attribute is missing or falsy, not just missing.
| node._files_processed = True | ||
|
|
||
| # Apply additional metadata fields | ||
| metadata_fields = [ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick: This metadata_fields list overlaps significantly with _METADATA_APPLY_FIELDS (defined on ContentNode at line 998). The two lists serve the same purpose (applying metadata to a node) but differ slightly (this one includes tags but omits thumbnail, author, aggregator, provider, role, etc.). Consider extracting a shared constant, or documenting why the child-building path intentionally uses a narrower set of fields.
|
|
||
| See _METADATA_APPLY_FIELDS for the allowlist and exclusion rationale. | ||
| """ | ||
| for field_name in self._METADATA_APPLY_FIELDS: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: _apply_content_node_metadata unconditionally overwrites list fields like learning_activities, resource_types, grade_levels, etc. via setattr. If the node had pre-existing values set in the constructor (e.g. by the sushi chef), the pipeline metadata would silently replace them rather than merge. Contrast with the tags handling two lines below (line 1061) which correctly appends. This may be intentional (pipeline metadata should take precedence for these fields) but it's worth a comment documenting the design choice, or making the merge-vs-replace behavior consistent.
| children = [] | ||
| for item in items: | ||
| fields = metadata_dict_to_content_node_fields(item.get("metadata", {})) | ||
| fields["title"] = item.get("title", fields.get("title")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: fields["title"] = item.get("title", fields.get("title")) — if the item has no "title" key and metadata_dict_to_content_node_fields also produced no title, fields["title"] will be set to None. Downstream in _build_children_from_metadata (nodes.py line 801), the fallback child_meta.title or "Untitled" handles this, but _content_node_metadata_from_dict will store None as the title. This is fine but slightly surprising — a missing title propagates as None through the metadata layer and only gets defaulted at the node-building layer.
| item[key_stripped] = value | ||
|
|
||
| resource_type = resource_elem.get("type", "") | ||
| if resource_type == "webcontent" or is_qti_resource(resource_type): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: if resource_type == "webcontent" or is_qti_resource(resource_type): — any other resource types (e.g. "associatedcontent/imscc_xmlv1p1/learning-application-resource") won't get files populated. This means resources of unrecognized types are silently dropped. Consider logging a warning for unrecognized resource types that have file references, similar to how dangling identifierref is logged on line 301.
| # Handle XML files that are marked as UTF-8 but have non-UTF-8 chars. | ||
| # Detect the real encoding with chardet and re-encode as UTF-8. | ||
| try: | ||
| f = zf.open("imsmanifest.xml", "r") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick: The chardet fallback opens the file in a non-context-manager style (f = zf.open(...); data = f.read(); f.close()). Using a with statement would be marginally cleaner:
with zf.open("imsmanifest.xml", "r") as f:
data = f.read()

Fixes #332
Fixes #337