Skip to content

Allow publishing of QTI zips in addition to perseus #4878

@rtibbles

Description

@rtibbles

This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Desired behavior

To support free response questions, we will be using QTI question rendering in Kolibri. To allow for this, if an assessment contains a free response question, we should publish the entire assessment as a QTI zip.

The best example to follow is Common Cartridge QTI package (CC QTI Package) linked here: https://www.imsglobal.org/spec/qti/v3p0/impl#h.9wxhfkbv60p0 - although the other sample packages may be illustrative.

Each outputted QTI file should contain a simple linear test with all of the questions as separate assessmentItems.

Sample XML for the choice (both single and multiple) and text entry (short free response) are available on the same page.

Technical architecture

In order to ensure that we are producing valid XML, we will use Python data classes to encode each level of the XML hierarchy in Python. There will be a base class XMLElement class that parses all the attributes of the dataclass and turns them into a Python ET.Element for XML rendering:

@dataclass
class TextNode:
    """Class to represent text nodes within XML elements"""
    text: str


class XMLElement(ABC):
    """Base class for XML elements"""
    
    @property
    @abstractmethod
    def element_name(self) -> str:
        """Return the XML element name"""
        pass

    def to_element(self) -> Union[ET.Element, List[ET.Element]]:
        element = ET.Element(self.element_name)
        
        # Add attributes based on dataclass fields
        for field in fields(self):
                
            value = getattr(self, field.name)
            
            # Skip None values
            if value is None:
                continue
                
            
            if isinstance(value, (XMLElement, TextNode)):
                value = [value]

            if isinstance(value, list):
                if all(isinstance(item, (XMLElement, TextNode)) for item in value):
                    for item in value:
                        if isinstance(item, XMLElement):
                            child_elements = item.to_element()
                            if not isinstance(child_elements, list):
                                child_elements = [child_elements]
                            for child_element in child_elements:
                                element.append(child_element)
                        else:
                            current_children = list(element)
                            if current_children:
                                current_children[-1].tail = (current_children[-1].tail or "") + item.text
                            else:
                                element.text = (element.text or "") + item.text

                    continue
                else:
                    raise ValueError("List types should only contain XMLElement or TextNodes")

            # Handle enum values
            if hasattr(value, 'value'):
                value = value.value
            
            # Some attribute names are reserved Python keywords or Python builtins
            # to allow this, we allow a trailing underscore which we strip here.
            # All attributes use kebab-case, which we can't easily use as field names
            # so we encode them as snake_case and convert to kebab-case here.
            attr_name = field.name.rstrip("_").replace('_', '-')

            # Set the attribute
            element.set(attr_name, str(value))

        return element

    def to_xml_string(self) -> bytes:
        """Convert to XML string"""
        element = self.to_element()
        return ET.tostring(element, encoding='utf-8')

If the child attributes are either a single XMLElement class, or a list of XMLElement classes, they are appended in order to the node. This also adds some special handling with a TextNode to allow interspersing text between elements.

This allows for enforcement of specific ordering of child elements within a parent, or for intermixed elements with type enforcement.

The goal of this issue will be to implement the following classes:

IMSManifest - representing the manifest file with direct references to XML files for individual AssessmentItems
AssessmentItem - representing a QTI assessment item, with inline comments for unneeded attributes and child elements. The necessary implementation of all needed elements within this to allow for ungraded short answer text entry (for math input), extended text entry, and multiple choice questions. This will include at least ItemBody, ChoiceInteraction, TextEntry, and ExtendedTextEntry.

Language should be encoded using a special BCP47Language type which enforces valid BCP47 tags, and any values that are restricted to specific possibilities should be encoded using enums.

Once all these classes have been implemented, a function will be created to convert Studio AssessmentItem models into the AssessmentItem XML dataclass, and then export to XML.

Current behavior

Currently all assessments are published as .perseus files.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions