From 6a66df195686a47c04c663ed45b7c7005c1f8d12 Mon Sep 17 00:00:00 2001 From: Jeremy A Gray Date: Sun, 12 Jan 2025 23:50:30 -0600 Subject: [PATCH] feat(vocutil): implement fill-in-the blank items Implement fill-in-the-blank items with the `Item` interface. Github-closes: jeremyagray/vocutil#10 Signed-off-by: Jeremy A Gray --- vocutil/cc/__init__.py | 3 +- vocutil/cc/fib.py | 149 ++++++++++++++++++++++++----------------- vocutil/cc/mc.py | 83 ++++++++++++++--------- vocutil/cc/test_fib.py | 72 ++++++++++++-------- vocutil/cc/test_mc.py | 11 +-- vocutil/cc/test_tf.py | 11 +-- vocutil/cc/tf.py | 60 +++++++++++------ 7 files changed, 228 insertions(+), 161 deletions(-) diff --git a/vocutil/cc/__init__.py b/vocutil/cc/__init__.py index 07073e7..39f8970 100644 --- a/vocutil/cc/__init__.py +++ b/vocutil/cc/__init__.py @@ -10,7 +10,7 @@ # # ****************************************************************************** -"""vocutil common cartridge module interface.""" +"""vocutil Common Cartridge module interface.""" from .assessment import Assessment from .bank import Bank @@ -23,3 +23,4 @@ from .resources import HTMLFileResource from .resources import QuestionBankResource from .resources import Resource +from .tf import TrueFalse diff --git a/vocutil/cc/fib.py b/vocutil/cc/fib.py index a16f052..d48ec75 100644 --- a/vocutil/cc/fib.py +++ b/vocutil/cc/fib.py @@ -2,7 +2,7 @@ # # vocutil, educational vocabulary utilities. # -# Copyright 2022-2024 Jeremy A Gray . +# Copyright 2022-2025 Jeremy A Gray . # # All rights reserved. # @@ -12,6 +12,7 @@ """Common Cartridge fill-in-the-blank item.""" +import json from xml.etree.ElementTree import Element as ETElement # nosec B405 from xml.etree.ElementTree import SubElement as ETSubElement # nosec B405 @@ -23,12 +24,24 @@ class FillInTheBlank(Item): """A Common Cartridge fill in the blank item.""" - def __init__(self, question, answers, case="No", **kwargs): - """Initialize a fill in the item.""" + def __init__(self, question, answers, default_case="No", **kwargs): + """Initialize a ``FillInTheBlank`` item. + + Initialize a ``FillInTheBlank`` item. + + Parameters + ---------- + question : str + The HTML formatted question text. + answers : [obj] + The correct answer(s). + """ + # Call the super. super().__init__(**kwargs) + self.question = question self.answers = answers - self.case = case if case == "Yes" else "No" + self.default_case = default_case if default_case == "Yes" else "No" self.item = ETElement("item", ident=str(self.uuid)) self.itemmetadata = ETSubElement(self.item, "itemmetadata") @@ -75,69 +88,85 @@ def __init__(self, question, answers, case="No", **kwargs): varequal = ETSubElement( condvar, "varequal", - case=self.case, + case=answer["case"] if "case" in answer else self.default_case, respident=f"fib-resp-{str(self.uuid)}", ) - varequal.text = answer + varequal.text = answer["answer"] setvar = ETSubElement(cond, "setvar", action="Set", varname="SCORE") setvar.text = "100" return - def to_xml(self): - """Initialize a fill in the blank item.""" - item = ETElement("item", ident=str(self.uuid)) - itemmetadata = ETSubElement(item, "itemmetadata") - qtimetadata = ETSubElement(itemmetadata, "qtimetadata") - qtimetadatafield = ETSubElement(qtimetadata, "qtimetadatafield") - fieldlabel = ETSubElement(qtimetadatafield, "fieldlabel") - fieldlabel.text = "cc_profile" - fieldentry = ETSubElement(qtimetadatafield, "fieldentry") - fieldentry.text = "cc.fib.v0p1" - presentation = ETSubElement(item, "presentation") - material = ETSubElement(presentation, "material") - mattext = ETSubElement(material, "mattext", texttype="text/html") - mattext.text = self.question - - response = ETSubElement( - presentation, - "response_str", - rcardinality="Single", - ident=f"fib-resp-{str(self.uuid)}", + @classmethod + def from_json(cls, item, **kwargs): + """Create a ``FillInTheBlank`` item from JSON data. + + Create a ``FillInTheBlank`` item from JSON fill-in-the-blank + item data. + + Parameters + ---------- + cls + The ``FillInTheBlank`` class. + item : str + A string containing JSON fill-in-the-blank data. + """ + data = json.loads(item) + + return cls(data["question"], data["answers"], **kwargs) + + @classmethod + def from_xml(cls, item, **kwargs): + """Create a ``FillInTheBlank`` item XML data. + + Create a ``FillInTheBlank`` item from IMSCC fill-in-the-blank + XML data. + + Parameters + ---------- + cls + The ``FillInTheBlank`` class. + item : str + A string containing IMSCC fill-in-the-blank XML data. + """ + tree = ET.fromstring(item) + + # Question text. + q = tree.find("presentation").find("material").find("mattext").text + + # Correct answers. + a = [ + { + "answer": ele.text, + "case": ele.get("case"), + } + for ele in tree.find("resprocessing") + .find("respcondition") + .find("conditionvar") + .findall("varequal") + ] + + return cls(q, a, **kwargs) + + def to_json(self): + """Create a fill-in-the-blank JSON string from item data.""" + return json.dumps( + { + "question": str(self.mattext.text), + "answers": [ + { + "answer": ele.text, + "case": ele.get("case"), + } + for ele in self.item.find("resprocessing") + .find("respcondition") + .find("conditionvar") + .findall("varequal") + ], + } ) - ETSubElement(response, "render_fib", prompt="Dashline") - grade = ETSubElement(item, "resprocessing") - outcomes = ETSubElement(grade, "outcomes") - ETSubElement( - outcomes, - "decvar", - maxvalue="100", - minvalue="0", - varname="SCORE", - vartype="Decimal", - ) - cond = ETSubElement( - grade, - "respcondition", - attrib={ - "continue": "No", - }, - ) - condvar = ETSubElement(cond, "conditionvar") - - # Set possible correct answers. - for answer in self.answers: - varequal = ETSubElement( - condvar, - "varequal", - case=self.case, - respident=f"fib-resp-{str(self.uuid)}", - ) - varequal.text = answer - - setvar = ETSubElement(cond, "setvar", action="Set", varname="SCORE") - setvar.text = "100" - - return ET.tostring(item, encoding="unicode") + def to_xml(self): + """Create a fill-in-the-blank XML string from item data.""" + return ET.tostring(self.item, encoding="unicode") diff --git a/vocutil/cc/mc.py b/vocutil/cc/mc.py index b4d02de..592cf1b 100644 --- a/vocutil/cc/mc.py +++ b/vocutil/cc/mc.py @@ -25,11 +25,16 @@ class MultipleChoice(Item): """A Common Cartridge multiple choice item.""" def __init__(self, question, answers, **kwargs): - """Initialize a multiple choice item. + """Initialize a ``MultipleChoice`` item. - Initialize a multiple choice item by storing the question and - answer data as plain Python objects for later output and as - IMSCC XML elements. + Initialize a ``MultipleChoice`` item. + + Parameters + ---------- + question : str + The HTML formatted question text. + answers : [obj] + The possible answers. """ # Call the super. super().__init__(**kwargs) @@ -93,36 +98,34 @@ def __init__(self, question, answers, **kwargs): return @classmethod - def from_json(cls, data, **kwargs): - """Instantiate from JSON data. + def from_json(cls, item, **kwargs): + """Create a ``MultipleChoice`` item from JSON data. - Instantiate from JSON data. + Create a ``MultipleChoice`` item from JSON data. Parameters ---------- cls The ``MultipleChoice`` class. - data : str - A string containing JSON data with which to generate the - PDF. + item : str + A string containing JSON multiple choice data. """ - d = json.loads(data) + data = json.loads(item) - return cls(d["question"], d["answers"], **kwargs) + return cls(data["question"], data["answers"], **kwargs) @classmethod def from_xml(cls, item, **kwargs): - """Instantiate from IMSCC multiple choice item XML data. + """Create a ``MultipleChoice`` item from XML data. - Instantiate from IMSCC multiple choice item XML data. + Create a ``MultipleChoice`` item from XML data. Parameters ---------- cls The ``MultipleChoice`` class. item : str - A string containing IMSCC multiple choice XML data with - which to generate the item. + A string containing IMSCC multiple choice XML data. """ tree = ET.fromstring(item) @@ -139,31 +142,45 @@ def from_xml(cls, item, **kwargs): ) # Answer choices. - a = [] - for ele in ( - tree.find("presentation") - .find("response_lid") - .find("render_choice") - .findall("response_label") - ): - a.append( - { - "answer": ele.find("material").find("mattext").text, - "correct": True if correct == ele.get("ident") else False, - } + a = [ + { + "answer": ele.find("material").find("mattext").text, + "correct": True if correct == ele.get("ident") else False, + } + for ele in ( + tree.find("presentation") + .find("response_lid") + .find("render_choice") + .findall("response_label") ) + ] return cls(q, a, **kwargs) def to_json(self): - """Create JSON string from item data.""" + """Create a multiple choice JSON string from item data.""" + # Correct answer. + correct = ( + self.item.find("resprocessing") + .find("respcondition") + .find("conditionvar") + .find("varequal") + .text + ) + return json.dumps( { - "question": str(self.question), - "answers": self.answers, + "question": self.mattext.text, + "answers": [ + { + "answer": ele.find("material").find("mattext").text, + "correct": True if correct == ele.get("ident") else False, + } + for ele in self.choices.findall("response_label") + ], } ) def to_xml(self): - """Create XML string from item data.""" - return ET.tostring(self.item, encoding="unicode", xml_declaration=True) + """Create a multiple choice XML string from item data.""" + return ET.tostring(self.item, encoding="unicode") diff --git a/vocutil/cc/test_fib.py b/vocutil/cc/test_fib.py index c55f7d5..024a15f 100644 --- a/vocutil/cc/test_fib.py +++ b/vocutil/cc/test_fib.py @@ -2,7 +2,7 @@ # # vocutil, educational vocabulary utilities. # -# Copyright 2022-2024 Jeremy A Gray . +# Copyright 2022-2025 Jeremy A Gray . # # All rights reserved. # @@ -10,32 +10,31 @@ # # ****************************************************************************** -"""vocutil CC fill in the blank tests.""" +"""vocutil Common Cartridge fill-in-the-blank tests.""" -import sys - -sys.path.insert(0, "/home/gray/src/work/vocutil") - -import vocutil # noqa: E402 +import vocutil def test_one_correct_response_case_insensitive(): """Should produce correct XML for one correct, case insensitive response.""" qdata = { "question": "

_: the process of separating a wave of different frequencies into its individual component waves

", # noqa: E501 - "case": "No", "answers": [ - "dispersion", + { + "answer": "dispersion", + "case": "No", + }, ], } item = vocutil.cc.FillInTheBlank( - qdata["question"], qdata["answers"], case=qdata["case"] + qdata["question"], + qdata["answers"], ) - actual = f"""cc_profilecc.fib.v0p1<p>_: the process of separating a wave of different frequencies into its individual component waves</p>dispersion100""" # noqa: E501 + actual = item.to_xml() - expected = item.to_xml() + expected = f"""cc_profilecc.fib.v0p1<p>_: the process of separating a wave of different frequencies into its individual component waves</p>dispersion100""" # noqa: E501 assert actual == expected @@ -44,19 +43,22 @@ def test_one_correct_response_case_sensitive(): """Should produce correct XML for one correct, case sensitive response.""" qdata = { "question": "

_: the process of separating a wave of different frequencies into its individual component waves

", # noqa: E501 - "case": "Yes", "answers": [ - "dispersion", + { + "answer": "dispersion", + "case": "Yes", + }, ], } item = vocutil.cc.FillInTheBlank( - qdata["question"], qdata["answers"], case=qdata["case"] + qdata["question"], + qdata["answers"], ) - actual = f"""cc_profilecc.fib.v0p1<p>_: the process of separating a wave of different frequencies into its individual component waves</p>dispersion100""" # noqa: E501 + actual = item.to_xml() - expected = item.to_xml() + expected = f"""cc_profilecc.fib.v0p1<p>_: the process of separating a wave of different frequencies into its individual component waves</p>dispersion100""" # noqa: E501 assert actual == expected @@ -65,20 +67,26 @@ def test_two_correct_responses_case_insensitive(): """Should produce correct XML for two correct, case insensitive responses.""" qdata = { "question": "

_: the process of separating a wave of different frequencies into its individual component waves

", # noqa: E501 - "case": "No", "answers": [ - "dispersion", - "dispersions", + { + "answer": "dispersion", + "case": "No", + }, + { + "answer": "dispersions", + "case": "No", + }, ], } item = vocutil.cc.FillInTheBlank( - qdata["question"], qdata["answers"], case=qdata["case"] + qdata["question"], + qdata["answers"], ) - actual = f"""cc_profilecc.fib.v0p1<p>_: the process of separating a wave of different frequencies into its individual component waves</p>dispersiondispersions100""" # noqa: E501 + actual = item.to_xml() - expected = item.to_xml() + expected = f"""cc_profilecc.fib.v0p1<p>_: the process of separating a wave of different frequencies into its individual component waves</p>dispersiondispersions100""" # noqa: E501 assert actual == expected @@ -87,19 +95,25 @@ def test_two_correct_responses_case_sensitive(): """Should produce correct XML for two correct, case sensitive responses.""" qdata = { "question": "

_: the process of separating a wave of different frequencies into its individual component waves

", # noqa: E501 - "case": "Yes", "answers": [ - "dispersion", - "dispersions", + { + "answer": "dispersion", + "case": "Yes", + }, + { + "answer": "dispersions", + "case": "Yes", + }, ], } item = vocutil.cc.FillInTheBlank( - qdata["question"], qdata["answers"], case=qdata["case"] + qdata["question"], + qdata["answers"], ) - actual = f"""cc_profilecc.fib.v0p1<p>_: the process of separating a wave of different frequencies into its individual component waves</p>dispersiondispersions100""" # noqa: E501 + actual = item.to_xml() - expected = item.to_xml() + expected = f"""cc_profilecc.fib.v0p1<p>_: the process of separating a wave of different frequencies into its individual component waves</p>dispersiondispersions100""" # noqa: E501 assert actual == expected diff --git a/vocutil/cc/test_mc.py b/vocutil/cc/test_mc.py index a3b0fa0..aebaed6 100644 --- a/vocutil/cc/test_mc.py +++ b/vocutil/cc/test_mc.py @@ -10,12 +10,10 @@ # # ****************************************************************************** -"""vocutil CC multiple choice tests.""" +"""vocutil Common Cartridge multiple choice tests.""" import json -import defusedxml.ElementTree as ET - import vocutil @@ -45,12 +43,9 @@ def test_simple_mc(): item = vocutil.cc.MultipleChoice(qdata["question"], qdata["answers"]) - actual = f"""cc_profilecc.multiple_choice.v0p1<p>The three primary additive colors, when combined, will create this kind of light.</p>whiteblackcolorlessclear{str(item.uuid)}-0100""" # noqa: E501 + actual = item.to_xml() - expected = ET.tostring( - item.item, - encoding="unicode", - ) + expected = f"""cc_profilecc.multiple_choice.v0p1<p>The three primary additive colors, when combined, will create this kind of light.</p>whiteblackcolorlessclear{str(item.uuid)}-0100""" # noqa: E501 assert actual == expected diff --git a/vocutil/cc/test_tf.py b/vocutil/cc/test_tf.py index 41dc630..f35d49e 100644 --- a/vocutil/cc/test_tf.py +++ b/vocutil/cc/test_tf.py @@ -10,12 +10,10 @@ # # ****************************************************************************** -"""vocutil CC multiple choice tests.""" +"""vocutil Common Cartridge multiple choice tests.""" import json -import defusedxml.ElementTree as ET - import vocutil @@ -28,12 +26,9 @@ def test_simple_tf(): item = vocutil.cc.TrueFalse(qdata["question"], qdata["answer"]) - actual = f"""cc_profilecc.true_false.v0p1<p>One is one more than zero.</p>TrueFalse{str(item.uuid)}-01100""" # noqa: E501 + actual = item.to_xml() - expected = ET.tostring( - item.item, - encoding="unicode", - ) + expected = f"""cc_profilecc.true_false.v0p1<p>One is one more than zero.</p>TrueFalse{str(item.uuid)}-01100""" # noqa: E501 assert actual == expected diff --git a/vocutil/cc/tf.py b/vocutil/cc/tf.py index 09c1086..c64755b 100644 --- a/vocutil/cc/tf.py +++ b/vocutil/cc/tf.py @@ -25,7 +25,9 @@ class TrueFalse(Item): """A Common Cartridge true/false item.""" def __init__(self, question, answer, **kwargs): - """Initialize a true/false item. + """Initialize a ``TrueFalse`` item. + + Initialize a ``TrueFalse`` item. Parameters ---------- @@ -33,9 +35,6 @@ def __init__(self, question, answer, **kwargs): The HTML formatted question text. answer : bool The Boolean answer. - - Initialize a true/false item by storing the question and - answer data as plain Python objects and as IMSCC XML elements. """ # Call the super. super().__init__(**kwargs) @@ -106,36 +105,34 @@ def __init__(self, question, answer, **kwargs): return @classmethod - def from_json(cls, data, **kwargs): - """Instantiate a ``TrueFalse`` item from JSON data. + def from_json(cls, item, **kwargs): + """Create a ``TrueFalse`` item from JSON data. - Instantiate a ``TrueFalse`` item from true/false item JSON - data. + Create a ``TrueFalse`` item from JSON data. Parameters ---------- cls The ``TrueFalse`` class. - data : str - A string containing true/false item JSON data. + item : str + A string containing true/false JSON data. """ - d = json.loads(data) + data = json.loads(item) - return cls(d["question"], d["answer"], **kwargs) + return cls(data["question"], data["answer"], **kwargs) @classmethod def from_xml(cls, item, **kwargs): - """Instantiate from IMSCC true/false item XML data. + """Create a ``TrueFalse`` item from XML data. - Instantiate a ``TrueFalse`` item from IMSCC true/false item - XML data. + Create a ``TrueFalse`` item from XML data. Parameters ---------- cls - The ``MultipleChoice`` class. + The ``TrueFalse`` class. item : str - A string containing IMSCC true/false item XML data. + A string containing IMSCC true/false XML data. """ tree = ET.fromstring(item) @@ -170,14 +167,33 @@ def from_xml(cls, item, **kwargs): return cls(q, a, **kwargs) def to_json(self): - """Create true/false item JSON string from item data.""" + """Create a true/false JSON string from item data.""" + # Correct answer. + correct = ( + self.item.find("resprocessing") + .find("respcondition") + .find("conditionvar") + .find("varequal") + .text + ) + + for ele in self.choices.findall("response_label"): + if correct == ele.get("ident"): + answer = ( + True + if ele.find("material").find("mattext").text.lower() == "true" + else False + ) + return json.dumps( { - "question": str(self.question), - "answer": bool(self.answer), + "question": str( + self.item.find("presentation").find("material").find("mattext").text + ), + "answer": answer, } ) def to_xml(self): - """Create IMSCC true/false item XML string from item data.""" - return ET.tostring(self.item, encoding="unicode", xml_declaration=True) + """Create a true/false XML string from item data.""" + return ET.tostring(self.item, encoding="unicode")