Skip to content

Commit 3d934b0

Browse files
committed
Initial implementation of pydantic driven QTI XML generation.
1 parent d4f7218 commit 3d934b0

15 files changed

Lines changed: 110 additions & 84 deletions

File tree

contentcuration/contentcuration/tests/utils/qti/test_assessment_items.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ def test_long_text_question(self):
257257
<p>Sam</p>
258258
</blockquote>
259259
</div>
260-
<qti-extended-text-interaction response-identifier="RESPONSE">
260+
<qti-extended-text-interaction response-identifier="RESPONSE" min-strings="0" format="plain">
261261
<qti-prompt>Write Sam a postcard. Answer the questions. Write 23–30 words</qti-prompt>
262262
</qti-extended-text-interaction>
263263
</qti-item-body>

contentcuration/contentcuration/tests/utils/qti/test_html.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,12 @@ def test_embed_elements(self):
140140
)
141141

142142
def test_flow_elements(self):
143-
blockquote_element = Blockquote(children=["Test Blockquote"], cite="test.com")
143+
blockquote_element = Blockquote(
144+
children=["Test Blockquote"], cite="http://test.com"
145+
)
144146
self.assertEqual(
145147
blockquote_element.to_xml_string(),
146-
'<blockquote cite="test.com">Test Blockquote</blockquote>',
148+
'<blockquote cite="http://test.com/">Test Blockquote</blockquote>',
147149
)
148150

149151
div_element = Div(children=["Test Div"])

contentcuration/contentcuration/tests/utils/qti/test_imsmanifest.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22

33
from contentcuration.utils.assessment.qti.imsmanifest import Dependency
44
from contentcuration.utils.assessment.qti.imsmanifest import File
5-
from contentcuration.utils.assessment.qti.imsmanifest import IMSManifest
65
from contentcuration.utils.assessment.qti.imsmanifest import Item
6+
from contentcuration.utils.assessment.qti.imsmanifest import Manifest
77
from contentcuration.utils.assessment.qti.imsmanifest import Metadata
88
from contentcuration.utils.assessment.qti.imsmanifest import Organization
99
from contentcuration.utils.assessment.qti.imsmanifest import Organizations
1010
from contentcuration.utils.assessment.qti.imsmanifest import Resource
1111
from contentcuration.utils.assessment.qti.imsmanifest import Resources
1212

1313

14-
class TestIMSManifestXMLOutput(unittest.TestCase):
14+
class TestManifestXMLOutput(unittest.TestCase):
1515
def test_metadata_to_xml_string(self):
1616
metadata = Metadata(schema="test_schema", schemaversion="1.0")
1717
expected_xml = "<metadata><schema>test_schema</schema><schemaversion>1.0</schemaversion></metadata>"
@@ -93,7 +93,7 @@ def test_imsmanifest_to_xml_string(self):
9393
resources = Resources(
9494
resources=[Resource(identifier="res1", type_="webcontent")]
9595
)
96-
manifest = IMSManifest(
96+
manifest = Manifest(
9797
identifier="manifest1",
9898
version="1.0",
9999
metadata=metadata,
@@ -112,16 +112,17 @@ def test_imsmanifest_to_xml_string(self):
112112
)
113113
self.assertEqual(manifest.to_xml_string(), expected_xml)
114114

115-
manifest = IMSManifest()
115+
manifest = Manifest(identifier="democracy_manifest")
116116
expected_xml = (
117117
'<manifest xmlns="http://www.imsglobal.org/xsd/qti/qtiv3p0/imscp_v1p2" '
118118
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
119-
'xsi:schemaLocation="http://www.imsglobal.org/xsd/qti/qtiv3p0/imscp_v1p2 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqtiv3p0_imscpv1p2_v1p0.xsd" />' # noqa: E501
119+
'xsi:schemaLocation="http://www.imsglobal.org/xsd/qti/qtiv3p0/imscp_v1p2 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqtiv3p0_imscpv1p2_v1p0.xsd" ' # noqa: E501
120+
'identifier="democracy_manifest" />'
120121
)
121122
self.assertEqual(manifest.to_xml_string(), expected_xml)
122123

123124
def test_imsmanifest_full_integration(self):
124-
manifest = IMSManifest(
125+
manifest = Manifest(
125126
identifier="level1-T1-test-entry",
126127
version="1.0",
127128
metadata=Metadata(schema="QTI Package", schemaversion="3.0.0"),

contentcuration/contentcuration/tests/utils/qti/test_qti.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,8 @@ def test_text_entry_interaction_element(self):
125125
def test_extended_text_interaction_element(self):
126126
extended_text_interaction = ExtendedTextInteraction(
127127
response_identifier="extendedText1",
128-
rows=5,
129-
cols=30,
130128
placeholder_text="Enter your essay here.",
131129
prompt=Prompt(children=["What is truth?"]),
132130
)
133-
expected_xml = '<qti-extended-text-interaction response-identifier="extendedText1" rows="5" cols="30" placeholder-text="Enter your essay here."><qti-prompt>What is truth?</qti-prompt></qti-extended-text-interaction>' # noqa: E501
131+
expected_xml = '<qti-extended-text-interaction response-identifier="extendedText1" placeholder-text="Enter your essay here." min-strings="0" format="plain"><qti-prompt>What is truth?</qti-prompt></qti-extended-text-interaction>' # noqa: E501
134132
self.assertEqual(extended_text_interaction.to_xml_string(), expected_xml)

contentcuration/contentcuration/utils/assessment/qti/assessment_item.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from pydantic import AnyUrl
66
from pydantic import Field
7+
from pydantic import PositiveInt
78

89
from contentcuration.utils.assessment.qti.base import BaseSequence
910
from contentcuration.utils.assessment.qti.base import QTIBase
@@ -14,7 +15,6 @@
1415
from contentcuration.utils.assessment.qti.constants import View
1516
from contentcuration.utils.assessment.qti.fields import BCP47Language
1617
from contentcuration.utils.assessment.qti.fields import LocalHrefPath
17-
from contentcuration.utils.assessment.qti.fields import PositiveInt
1818
from contentcuration.utils.assessment.qti.html import BlockContentElement
1919
from contentcuration.utils.assessment.qti.interaction_types.base import BlockInteraction
2020

contentcuration/contentcuration/utils/assessment/qti/base.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,9 @@ def to_element(self) -> ET.Element: # noqa: C901
9999
element.text = (element.text or "") + item.text
100100

101101
continue
102-
else:
103-
raise ValueError(
104-
"List types should only contain XMLElement or TextNodes"
105-
)
102+
raise ValueError(
103+
"List types should only contain XMLElement or TextNodes"
104+
)
106105

107106
elif isinstance(value, bool):
108107
value = str(value).lower()

contentcuration/contentcuration/utils/assessment/qti/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class View(Enum):
3030
CANDIDATE = "candidate"
3131
PROCTOR = "proctor"
3232
SCORER = "scorer"
33-
TEST_CONDUCTOR = "testConstructor"
33+
TEST_CONSTRUCTOR = "testConstructor"
3434
TUTOR = "tutor"
3535

3636

contentcuration/contentcuration/utils/assessment/qti/fields.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@
77
from pydantic import Field
88

99

10-
PositiveInt = Annotated[int, Field(gt=0)]
11-
12-
1310
def validate_bcp47_language(value: str) -> str:
1411
"""Validate and normalize BCP47 language tag."""
1512
if not isinstance(value, str):
@@ -22,7 +19,7 @@ def validate_bcp47_language(value: str) -> str:
2219
# Validate and normalize using langcodes
2320
return LangCodesLanguage.get(value).to_tag()
2421
except ValueError as e:
25-
raise ValueError(f"Invalid BCP47 language tag: {e}")
22+
raise ValueError("Invalid BCP47 language tag") from e
2623

2724

2825
BCP47Language = Annotated[str, BeforeValidator(validate_bcp47_language)]
@@ -106,3 +103,16 @@ def validate_local_srcset(value: str) -> str:
106103
LocalHrefPath = Annotated[str, BeforeValidator(validate_local_href_path)]
107104
LocalSrcPath = Annotated[str, BeforeValidator(validate_local_src_path)]
108105
LocalSrcSet = Annotated[str, BeforeValidator(validate_local_srcset)]
106+
107+
108+
QTIIdentifier = Annotated[
109+
str,
110+
Field(
111+
pattern=r"^[a-zA-Z_][a-zA-Z0-9_\-.]{0,31}$",
112+
min_length=1,
113+
max_length=32,
114+
description="QTI XML identifier: must start with letter or underscore, "
115+
"contain only letters, digits, underscores, hyphens, and periods, "
116+
"no colons, max 32 characters",
117+
),
118+
]

contentcuration/contentcuration/utils/assessment/qti/html/base.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from typing import Type
66
from typing import Union
77

8+
from pydantic import model_validator
9+
810
from contentcuration.utils.assessment.qti.base import BaseSequence
911
from contentcuration.utils.assessment.qti.base import TextNode
1012
from contentcuration.utils.assessment.qti.fields import LocalSrcPath
@@ -142,6 +144,9 @@ class Source(HTMLElement):
142144
height: Optional[int] = None
143145
width: Optional[int] = None
144146

145-
@classmethod
146-
def element_name(cls):
147-
return "source"
147+
@model_validator(mode="after")
148+
def _check_src_and_srcset(self):
149+
# both None or both set
150+
if (self.src is None) == (self.srcset is None):
151+
raise ValueError("Exactly one of 'src' or 'srcset' must be specified")
152+
return self

contentcuration/contentcuration/utils/assessment/qti/html/flow.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Optional
22

33
from pydantic import Field
4+
from pydantic import HttpUrl
45

56
from contentcuration.utils.assessment.qti.html.base import BlockContentElement
67
from contentcuration.utils.assessment.qti.html.content_types import FlowContentList
@@ -17,7 +18,7 @@ class HTMLFlowContainer(BlockContentElement):
1718

1819

1920
class Blockquote(HTMLFlowContainer):
20-
cite: Optional[str] = None
21+
cite: Optional[HttpUrl] = None
2122

2223

2324
class Div(HTMLFlowContainer):

0 commit comments

Comments
 (0)