diff --git a/src/cc2olx/constants.py b/src/cc2olx/constants.py new file mode 100644 index 0000000..1482510 --- /dev/null +++ b/src/cc2olx/constants.py @@ -0,0 +1,11 @@ +OLX_STATIC_DIR = "static" +OLX_STATIC_PATH_TEMPLATE = f"/{OLX_STATIC_DIR}/{{static_filename}}" +WEB_RESOURCES_DIR_NAME = "web_resources" + +WEB_LINK_NAMESPACE = ( + "http://www.imsglobal.org/xsd/imsccv{major_version}p{minor_version}/imswl_v{major_version}p{minor_version}" +) +YOUTUBE_LINK_PATTERN = r"youtube.com/watch\?v=(?P[-\w]+)" +LINK_HTML = "{text}" + +QTI_RESPROCESSING_TYPES = ["general_fb", "correct_fb", "general_incorrect_fb"] diff --git a/src/cc2olx/content_parsers/__init__.py b/src/cc2olx/content_parsers/__init__.py new file mode 100644 index 0000000..8f8bfe9 --- /dev/null +++ b/src/cc2olx/content_parsers/__init__.py @@ -0,0 +1,6 @@ +from cc2olx.content_parsers.abc import AbstractContentParser +from cc2olx.content_parsers.discussion import DiscussionContentParser +from cc2olx.content_parsers.html import HtmlContentParser +from cc2olx.content_parsers.lti import LtiContentParser +from cc2olx.content_parsers.qti import QtiContentParser +from cc2olx.content_parsers.video import VideoContentParser diff --git a/src/cc2olx/content_parsers/abc.py b/src/cc2olx/content_parsers/abc.py new file mode 100644 index 0000000..355fab4 --- /dev/null +++ b/src/cc2olx/content_parsers/abc.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod +from typing import Optional, Union + +from cc2olx.content_parsers.utils import StaticLinkProcessor +from cc2olx.models import Cartridge + + +class AbstractContentParser(ABC): + """ + Abstract base class for parsing Common Cartridge content. + """ + + def __init__(self, cartridge: Cartridge) -> None: + self._cartridge = cartridge + + def parse(self, idref: Optional[str]) -> Optional[Union[list, dict]]: + """ + Parse the resource with the specified identifier. + """ + if content := self._parse_content(idref): + link_processor = StaticLinkProcessor(self._cartridge) + content = link_processor.process_content_static_links(content) + return content + + @abstractmethod + def _parse_content(self, idref: Optional[str]) -> Optional[Union[list, dict]]: + """ + Parse content of the resource with the specified identifier. + """ diff --git a/src/cc2olx/content_parsers/discussion.py b/src/cc2olx/content_parsers/discussion.py new file mode 100644 index 0000000..961b1e7 --- /dev/null +++ b/src/cc2olx/content_parsers/discussion.py @@ -0,0 +1,51 @@ +import re +from typing import Dict, Optional + +from cc2olx import filesystem +from cc2olx.content_parsers import AbstractContentParser +from cc2olx.enums import CommonCartridgeResourceType +from cc2olx.models import ResourceFile + + +class DiscussionContentParser(AbstractContentParser): + """ + Discussion resource content parser. + """ + + NAMESPACES = { + "imsdt_xmlv1p1": "http://www.imsglobal.org/xsd/imsccv1p1/imsdt_v1p1", + "imsdt_xmlv1p2": "http://www.imsglobal.org/xsd/imsccv1p2/imsdt_v1p2", + "imsdt_xmlv1p3": "http://www.imsglobal.org/xsd/imsccv1p3/imsdt_v1p3", + } + + def _parse_content(self, idref: Optional[str]) -> Optional[Dict[str, str]]: + if ( + idref + and (resource := self._cartridge.define_resource(idref)) + and re.match(CommonCartridgeResourceType.DISCUSSION_TOPIC, resource["type"]) + ): + data = self._parse_discussion(resource) + return data + + def _parse_discussion(self, resource: dict) -> Dict[str, str]: + """ + Parse the discussion content. + """ + data = {} + + for child in resource["children"]: + if isinstance(child, ResourceFile): + data.update(self._parse_resource_file_data(child, resource["type"])) + + return data + + def _parse_resource_file_data(self, resource_file: ResourceFile, resource_type: str) -> Dict[str, str]: + """ + Parse the discussion resource file. + """ + tree = filesystem.get_xml_tree(self._cartridge.build_res_file_path(resource_file.href)) + root = tree.getroot() + ns = {"dt": self.NAMESPACES[resource_type]} + title = root.find("dt:title", ns).text + text = root.find("dt:text", ns).text + return {"title": title, "text": text} diff --git a/src/cc2olx/content_parsers/html.py b/src/cc2olx/content_parsers/html.py new file mode 100644 index 0000000..bbc3277 --- /dev/null +++ b/src/cc2olx/content_parsers/html.py @@ -0,0 +1,133 @@ +import imghdr +import logging +import re +from pathlib import Path +from typing import Dict, Optional + +from cc2olx import settings +from cc2olx.constants import LINK_HTML, OLX_STATIC_PATH_TEMPLATE, WEB_RESOURCES_DIR_NAME +from cc2olx.content_parsers import AbstractContentParser +from cc2olx.content_parsers.mixins import WebLinkParserMixin +from cc2olx.enums import CommonCartridgeResourceType + +logger = logging.getLogger() + +HTML_FILENAME_SUFFIX = ".html" + + +class HtmlContentParser(WebLinkParserMixin, AbstractContentParser): + """ + HTML resource content parser. + """ + + DEFAULT_CONTENT = {"html": "

MISSING CONTENT

"} + + def _parse_content(self, idref: Optional[str]) -> Dict[str, str]: + if idref: + if (resource := self._cartridge.define_resource(idref)) is None: + logger.info("Missing resource: %s", idref) + return self.DEFAULT_CONTENT + + if resource["type"] == CommonCartridgeResourceType.WEB_CONTENT: + content = self._parse_webcontent(idref, resource) + elif web_link_content := self._parse_web_link_content(resource): + content = self._transform_web_link_content_to_html(web_link_content) + elif ( + any( + re.match(resource_type, resource["type"]) for resource_type + in ( + CommonCartridgeResourceType.LTI_LINK, + CommonCartridgeResourceType.QTI_ASSESSMENT, + CommonCartridgeResourceType.DISCUSSION_TOPIC, + ) + ) + ): + content = self.DEFAULT_CONTENT + else: + content = self._parse_not_imported_content(resource) + return content + return self.DEFAULT_CONTENT + + def _parse_webcontent(self, idref: str, resource: dict) -> Dict[str, str]: + """ + Parse the resource with "webcontent" type. + """ + res_relative_path = resource["children"][0].href + res_file_path = self._cartridge.build_res_file_path(res_relative_path) + + if res_file_path.suffix == HTML_FILENAME_SUFFIX: + content = self._parse_webcontent_html_file(idref, res_file_path) + elif WEB_RESOURCES_DIR_NAME in str(res_file_path) and imghdr.what(str(res_file_path)): + content = self._parse_image_webcontent_from_web_resources_dir(res_file_path) + elif WEB_RESOURCES_DIR_NAME not in str(res_file_path): + content = self._parse_webcontent_outside_web_resources_dir(res_relative_path) + else: + logger.info("Skipping webcontent: %s", res_file_path) + content = self.DEFAULT_CONTENT + + return content + + @staticmethod + def _parse_webcontent_html_file(idref: str, res_file_path: Path) -> Dict[str, str]: + """ + Parse webcontent HTML file. + """ + try: + with open(res_file_path, encoding="utf-8") as res_file: + html = res_file.read() + except: # noqa: E722 + logger.error("Failure reading %s from id %s", res_file_path, idref) # noqa: E722 + raise + return {"html": html} + + @staticmethod + def _parse_image_webcontent_from_web_resources_dir(res_file_path: Path) -> Dict[str, str]: + """ + Parse webcontent image from "web_resources" directory. + """ + static_filename = str(res_file_path).split(f"{WEB_RESOURCES_DIR_NAME}/")[1] + olx_static_path = OLX_STATIC_PATH_TEMPLATE.format(static_filename=static_filename) + image_webcontent_tpl_path = settings.TEMPLATES_DIR / "image_webcontent.html" + + with open(image_webcontent_tpl_path, encoding="utf-8") as image_webcontent_tpl: + tpl_content = image_webcontent_tpl.read() + html = tpl_content.format(olx_static_path=olx_static_path, static_filename=static_filename) + + return {"html": html} + + def _parse_webcontent_outside_web_resources_dir(self, res_relative_path: str) -> Dict[str, str]: + """ + Parse webcontent located outside "web_resources" directory. + """ + # This webcontent is outside ``web_resources`` directory + # So we need to manually copy it to OLX_STATIC_DIR + self._cartridge.add_extra_static_file(res_relative_path) + olx_static_path = OLX_STATIC_PATH_TEMPLATE.format(static_filename=res_relative_path) + external_webcontent_tpl_path = settings.TEMPLATES_DIR / "external_webcontent.html" + + with open(external_webcontent_tpl_path, encoding="utf-8") as external_webcontent_tpl: + tpl_content = external_webcontent_tpl.read() + html = tpl_content.format(olx_static_path=olx_static_path, res_relative_path=res_relative_path) + + return {"html": html} + + @staticmethod + def _transform_web_link_content_to_html(web_link_content: Dict[str, str]) -> Dict[str, str]: + """ + Generate HTML for weblink. + """ + video_link_html = LINK_HTML.format(url=web_link_content["href"], text=web_link_content.get("text", "")) + return {"html": video_link_html} + + @staticmethod + def _parse_not_imported_content(resource: dict) -> Dict[str, str]: + """ + Parse the resource which content type cannot be processed. + """ + resource_type = resource["type"] + text = f"Not imported content: type = {resource_type!r}" + if "href" in resource: + text += ", href = {!r}".format(resource["href"]) + + logger.info("%s", text) + return {"html": text} diff --git a/src/cc2olx/content_parsers/lti.py b/src/cc2olx/content_parsers/lti.py new file mode 100644 index 0000000..e8864d3 --- /dev/null +++ b/src/cc2olx/content_parsers/lti.py @@ -0,0 +1,97 @@ +import re +from typing import Dict, Optional + +from lxml import etree + +from cc2olx import filesystem +from cc2olx.content_parsers import AbstractContentParser +from cc2olx.enums import CommonCartridgeResourceType +from cc2olx.utils import simple_slug + + +class LtiContentParser(AbstractContentParser): + """ + LTI resource content parser. + """ + + NAMESPACES = { + "blti": "http://www.imsglobal.org/xsd/imsbasiclti_v1p0", + "lticp": "http://www.imsglobal.org/xsd/imslticp_v1p0", + "lticm": "http://www.imsglobal.org/xsd/imslticm_v1p0", + } + DEFAULT_WIDTH = "500" + DEFAULT_HEIGHT = "500" + + def _parse_content(self, idref: Optional[str]) -> Optional[dict]: + if ( + idref + and (resource := self._cartridge.define_resource(idref)) + and re.match(CommonCartridgeResourceType.LTI_LINK, resource["type"]) + ): + data = self._parse_lti(resource) + # Canvas flavored courses have correct url in module meta for lti links + if self._cartridge.is_canvas_flavor: + if item_data := self._cartridge.module_meta.get_external_tool_item_data(idref): + data["launch_url"] = item_data.get("url", data["launch_url"]) + return data + return None + + def _parse_lti(self, resource: dict) -> dict: + """ + Parse LTI resource. + """ + res_file_path = self._cartridge.build_res_file_path(resource["children"][0].href) + tree = filesystem.get_xml_tree(res_file_path) + root = tree.getroot() + title = root.find("blti:title", self.NAMESPACES).text + description = root.find("blti:description", self.NAMESPACES).text + data = { + "title": title, + "description": description, + "launch_url": self._parse_launch_url(root), + "height": self._parse_height(root), + "width": self._parse_width(root), + "custom_parameters": self._parse_custom_parameters(root), + "lti_id": self._parse_lti_id(root, title), + } + return data + + def _parse_launch_url(self, resource_root: etree._Element) -> str: + """ + Parse URL to launch LTI. + """ + if (launch_url := resource_root.find("blti:secure_launch_url", self.NAMESPACES)) is None: + launch_url = resource_root.find("blti:launch_url", self.NAMESPACES) + return "" if launch_url is None else launch_url.text + + def _parse_width(self, resource_root: etree._Element) -> str: + """ + Parse width. + """ + width = resource_root.find("blti:extensions/lticm:property[@name='selection_width']", self.NAMESPACES) + return self.DEFAULT_WIDTH if width is None else width.text + + def _parse_height(self, resource_root: etree._Element) -> str: + """ + Parse height. + """ + height = resource_root.find("blti:extensions/lticm:property[@name='selection_height']", self.NAMESPACES) + return self.DEFAULT_HEIGHT if height is None else height.text + + def _parse_custom_parameters(self, resource_root: etree._Element) -> Dict[str, str]: + """ + Parse custom parameters. + """ + custom = resource_root.find("blti:custom", self.NAMESPACES) + return {} if custom is None else {option.get("name"): option.text for option in custom} + + def _parse_lti_id(self, resource_root: etree._Element, title: str) -> str: + """ + Parse LTI identifier. + """ + # For Canvas flavored CC, tool_id can be used as lti_id if present + tool_id = resource_root.find("blti:extensions/lticm:property[@name='tool_id']", self.NAMESPACES) + return ( + simple_slug(title) if tool_id is None # Create a simple slug lti_id from title + else tool_id.text + ) diff --git a/src/cc2olx/content_parsers/mixins.py b/src/cc2olx/content_parsers/mixins.py new file mode 100644 index 0000000..b0c9391 --- /dev/null +++ b/src/cc2olx/content_parsers/mixins.py @@ -0,0 +1,40 @@ +import re +from typing import Dict, Optional + +from cc2olx import filesystem +from cc2olx.constants import WEB_LINK_NAMESPACE +from cc2olx.enums import CommonCartridgeResourceType +from cc2olx.models import Cartridge + + +class WebLinkParserMixin: + """ + Provide Common Cartridge Web Link resource parsing functionality. + """ + + _cartridge: Cartridge + + def _parse_web_link_content(self, resource: dict) -> Optional[Dict[str, str]]: + """ + Provide Web Link resource data. + """ + if web_link_match := re.match(CommonCartridgeResourceType.WEB_LINK, resource["type"]): + res_file_path = self._cartridge.build_res_file_path(resource["children"][0].href) + tree = filesystem.get_xml_tree(res_file_path) + root = tree.getroot() + ns = self._build_web_link_namespace(web_link_match) + title = root.find("wl:title", ns).text + url = root.find("wl:url", ns).get("href") + return {"href": url, "text": title} + return None + + @staticmethod + def _build_web_link_namespace(web_link_match: re.Match) -> Dict[str, str]: + """ + Build Web Link namespace. + """ + web_link = WEB_LINK_NAMESPACE.format( + major_version=web_link_match.group("major_version"), + minor_version=web_link_match.group("minor_version"), + ) + return {"wl": web_link} diff --git a/src/cc2olx/content_parsers/qti.py b/src/cc2olx/content_parsers/qti.py new file mode 100644 index 0000000..b1357e1 --- /dev/null +++ b/src/cc2olx/content_parsers/qti.py @@ -0,0 +1,414 @@ +import logging +import re +from collections import OrderedDict +from pathlib import Path +from typing import Callable, Dict, List, Optional, Union + +from lxml import etree + +from cc2olx import filesystem +from cc2olx.constants import QTI_RESPROCESSING_TYPES +from cc2olx.content_parsers import AbstractContentParser +from cc2olx.dataclasses import FibProblemRawAnswers +from cc2olx.enums import CommonCartridgeResourceType, QtiQuestionType +from cc2olx.exceptions import QtiError + +logger = logging.getLogger() + + +class QtiContentParser(AbstractContentParser): + """ + QTI resource content parser. + """ + + NAMESPACES = {"qti": "http://www.imsglobal.org/xsd/ims_qtiasiv1p2"} + + def _parse_content(self, idref: Optional[str]) -> Optional[List[dict]]: + if ( + idref + and (resource := self._cartridge.define_resource(idref)) + and re.match(CommonCartridgeResourceType.QTI_ASSESSMENT, resource["type"]) + ): + res_file_path = self._cartridge.build_res_file_path(resource["children"][0].href) + return self._parse_qti(res_file_path) + return None + + def _parse_qti(self, res_file_path: Path) -> List[dict]: + """ + Parse resource of ``imsqti_xmlv1p2/imscc_xmlv1p1/assessment`` type. + """ + tree = filesystem.get_xml_tree(res_file_path) + root = tree.getroot() + + # qti xml can contain multiple problems represented by elements + problems = root.findall(".//qti:section/qti:item", self.NAMESPACES) + + parsed_problems = [] + + for index, problem in enumerate(problems): + parsed_problems.append(self._parse_problem(problem, index, res_file_path)) + + return parsed_problems + + def _parse_problem(self, problem: etree._Element, problem_index: int, res_file_path: Path) -> dict: + """ + Parse a QTI item. + """ + data = {} + + attributes = problem.attrib + + # We're adding unique string to identifier here to handle cases, + # when we're getting malformed course (due to a weird Canvas behaviour) + # with equal identifiers. LMS doesn't support blocks with the same identifiers. + data["ident"] = attributes["ident"] + str(problem_index) + if title := attributes.get("title"): + data["title"] = title + + cc_profile = self._parse_problem_profile(problem) + data["cc_profile"] = cc_profile + + parse_problem = self._problem_parsers_map.get(cc_profile) + + if parse_problem is None: + raise QtiError(f'Unknown cc_profile: "{cc_profile}"') + + try: + data.update(parse_problem(problem)) + except NotImplementedError: + logger.info("Problem with ID %s can't be converted.", problem.attrib.get("ident")) + logger.info(" Profile %s is not supported.", cc_profile) + logger.info(" At file %s.", res_file_path) + + return data + + def _parse_problem_profile(self, problem: etree._Element) -> str: + """ + Return ``cc_profile`` value from problem metadata. + + This field is mandatory for problem, so the exception is thrown if + it's not present. + + Example of metadata structure: + ``` + + + + cc_profile + cc.true_false.v0p1 + + + + ``` + """ + metadata = problem.findall("qti:itemmetadata/qti:qtimetadata/qti:qtimetadatafield", self.NAMESPACES) + + for field in metadata: + label = field.find("qti:fieldlabel", self.NAMESPACES).text + entry = field.find("qti:fieldentry", self.NAMESPACES).text + + if label == "cc_profile": + return entry + + raise ValueError('Problem metadata must contain "cc_profile" field.') + + @property + def _problem_parsers_map(self) -> Dict[QtiQuestionType, Callable[[etree._Element], dict]]: + """ + Provide mapping between CC profile value and problem node type parser. + + Note: Since True/False problems in QTI are constructed identically to + QTI Multiple Choice problems, we reuse `_parse_multiple_choice_problem` + for BOOLEAN type problems. + """ + return { + QtiQuestionType.MULTIPLE_CHOICE: self._parse_multiple_choice_problem, + QtiQuestionType.MULTIPLE_RESPONSE: self._parse_multiple_response_problem, + QtiQuestionType.FILL_IN_THE_BLANK: self._parse_fib_problem, + QtiQuestionType.ESSAY: self._parse_essay_problem, + QtiQuestionType.BOOLEAN: self._parse_multiple_choice_problem, + QtiQuestionType.PATTERN_MATCH: self._parse_pattern_match_problem, + } + + def _parse_fixed_answer_question_responses( + self, + presentation: etree._Element, + ) -> OrderedDict[str, Dict[str, Union[bool, str]]]: + """ + Provide mapping with response IDs as keys and response data as values. + + Example of ```` structure for the following profiles: + - ``cc.multiple_choice.v0p1`` + - ``cc.multiple_response.v0p1`` + - ``cc.true_false.v0p1`` + ``` + + + + + Response 1 + + + + + Response 2 + + + + + ``` + """ + responses = OrderedDict() + + for response in presentation.findall("qti:response_lid/qti:render_choice/qti:response_label", self.NAMESPACES): + response_id = response.attrib["ident"] + responses[response_id] = { + "text": response.find("qti:material/qti:mattext", self.NAMESPACES).text or "", + "correct": False, + } + + return responses + + def _mark_correct_responses(self, resprocessing: etree._Element, responses: OrderedDict) -> None: + """ + Add the information about correctness to responses data. + + Example of ```` structure for the following profiles: + - ``cc.multiple_choice.v0p1`` + - ``cc.true_false.v0p1`` + ``` + + + + + + + 8157 + + + + + + 5534 + + + + + + 4226 + + 100 + + + + ``` + + This XML is a sort of instruction about how responses should be evaluated. In this + particular example we have three correct answers with ids: 8157, 5534, 4226. + + Example of ```` structure for ``cc.multiple_response.v0p1``: + ``` + + + + + + + + 1759 + + 5954 + + 8170 + 9303 + + 15 + + + + + + ``` + Above example is for a multiple response type problem. In this example 1759, 8170 and + 9303 are correct answers while 15 and 5954 are not. Note that this code also support + ``or`` opearator too. + + For now, we just consider these responses correct in OLX, but according specification, + conditions can be arbitrarily nested, and score can be computed by some formula, so to + implement 100% conversion we need to write new XBlock. + """ + for respcondition in resprocessing.findall("qti:respcondition", self.NAMESPACES): + correct_answers = respcondition.findall("qti:conditionvar/qti:varequal", self.NAMESPACES) + + if len(correct_answers) == 0: + correct_answers = respcondition.findall("qti:conditionvar/qti:and/qti:varequal", self.NAMESPACES) + correct_answers += respcondition.findall("qti:conditionvar/qti:or/qti:varequal", self.NAMESPACES) + + for answer in correct_answers: + responses[answer.text]["correct"] = True + + if respcondition.attrib.get("continue", "No") == "No": + break + + def _parse_multiple_choice_problem(self, problem: etree._Element) -> dict: + """ + Provide the multiple choice problem data. + """ + data = {} + + presentation = problem.find("qti:presentation", self.NAMESPACES) + resprocessing = problem.find("qti:resprocessing", self.NAMESPACES) + + data["problem_description"] = presentation.find("qti:material/qti:mattext", self.NAMESPACES).text + + data["choices"] = self._parse_fixed_answer_question_responses(presentation) + self._mark_correct_responses(resprocessing, data["choices"]) + + return data + + def _parse_multiple_response_problem(self, problem: etree._Element) -> dict: + """ + Provide the multiple response problem data. + """ + return self._parse_multiple_choice_problem(problem) + + def _parse_fib_problem(self, problem: etree._Element) -> dict: + """ + Provide the Fill-In-The-Blank problem data. + """ + return { + "problem_description": self._parse_fib_problem_description(problem), + **self._parse_fib_problem_answers(problem), + } + + def _parse_fib_problem_description(self, problem: etree._Element) -> str: + """ + Parse the Fill-In-The-Blank problem description. + """ + presentation = problem.find("qti:presentation", self.NAMESPACES) + return presentation.find("qti:material/qti:mattext", self.NAMESPACES).text + + def _parse_fib_problem_answers(self, problem: etree._Element) -> dict: + """ + Parse the Fill-In-The-Blank problem answers data. + """ + raw_answers = self._parse_fib_problem_raw_answers(problem) + + data = {"is_regexp": bool(raw_answers.answer_patterns)} + + if data["is_regexp"]: + data.update(self._build_fib_problem_regexp_answers(raw_answers)) + else: + data.update(self._build_fib_problem_exact_answers(raw_answers)) + return data + + def _parse_fib_problem_raw_answers(self, problem: etree._Element) -> FibProblemRawAnswers: + """ + Parse the Fill-In-The-Blank problem answers without processing. + """ + exact_answers = [] + answer_patterns = [] + + resprocessing = problem.find("qti:resprocessing", self.NAMESPACES) + + for respcondition in resprocessing.findall("qti:respcondition", self.NAMESPACES): + for varequal in respcondition.findall("qti:conditionvar/qti:varequal", self.NAMESPACES): + exact_answers.append(varequal.text) + + for varsubstring in respcondition.findall("qti:conditionvar/qti:varsubstring", self.NAMESPACES): + answer_patterns.append(varsubstring.text) + + if respcondition.attrib.get("continue", "No") == "No": + break + + return FibProblemRawAnswers(exact_answers, answer_patterns) + + @staticmethod + def _build_fib_problem_regexp_answers(raw_answers: FibProblemRawAnswers) -> dict: + """ + Build the Fill-In-The-Blank problem regular expression answers data. + """ + exact_answers = raw_answers.exact_answers.copy() + answer_patterns = raw_answers.answer_patterns.copy() + + data = {"answer": answer_patterns.pop(0)} + exact_answers = [re.escape(answer) for answer in exact_answers] + data["additional_answers"] = [*answer_patterns, *exact_answers] + + return data + + @staticmethod + def _build_fib_problem_exact_answers(raw_answers: FibProblemRawAnswers) -> dict: + """ + Build the Fill-In-The-Blank problem exact answers data. + """ + # Primary answer is the first one, additional answers are what is left + exact_answers = raw_answers.exact_answers.copy() + + return { + "answer": exact_answers.pop(0), + "additional_answers": exact_answers, + } + + def _parse_essay_problem(self, problem: etree._Element) -> dict: + """ + Parse `cc.essay.v0p1` problem type. + + Provide a dictionary with presentation & sample solution if exists. + """ + data = { + "problem_description": self._parse_essay_description(problem), + **self._parse_essay_feedback(problem), + } + + if sample_solution := self._parse_essay_sample_solution(problem): + data["sample_solution"] = sample_solution + + return data + + def _parse_essay_description(self, problem: etree._Element) -> str: + """ + Parse the essay description. + """ + presentation = problem.find("qti:presentation", self.NAMESPACES) + return presentation.find("qti:material/qti:mattext", self.NAMESPACES).text + + def _parse_essay_sample_solution(self, problem: etree._Element) -> Optional[str]: + """ + Parse the essay sample solution. + """ + if (solution := problem.find("qti:itemfeedback/qti:solution", self.NAMESPACES)) is not None: + sample_solution_selector = "qti:solutionmaterial//qti:material//qti:mattext" + return solution.find(sample_solution_selector, self.NAMESPACES).text + return None + + def _parse_essay_feedback(self, problem: etree._Element) -> dict: + """ + Parse the essay feedback. + """ + data = {} + itemfeedback = problem.find("qti:itemfeedback", self.NAMESPACES) + + if itemfeedback is not None: + for resp_type in QTI_RESPROCESSING_TYPES: + response_text = self._parse_essay_response_processing(problem, resp_type) + if response_text: + data[resp_type] = response_text + + return data + + def _parse_essay_response_processing(self, problem: etree._Element, resp_type: str) -> Optional[str]: + """ + Parse the essay response processing. + """ + respconditions = problem.find("qti:resprocessing/qti:respcondition", self.NAMESPACES) + if respconditions.find(f"qti:displayfeedback[@linkrefid='{resp_type}']", self.NAMESPACES) is not None: + text_selector = f"qti:itemfeedback[@ident='{resp_type}']/qti:flow_mat/qti:material/qti:mattext" + return problem.find(text_selector, self.NAMESPACES).text + return None + + def _parse_pattern_match_problem(self, problem: etree._Element) -> dict: + """ + Provide the pattern match problem data. + """ + raise NotImplementedError diff --git a/src/cc2olx/content_parsers/utils.py b/src/cc2olx/content_parsers/utils.py new file mode 100644 index 0000000..3feb538 --- /dev/null +++ b/src/cc2olx/content_parsers/utils.py @@ -0,0 +1,111 @@ +import html as html_parser +import logging +import re +import urllib +from typing import TypeVar + +from cc2olx.dataclasses import LinkKeywordProcessor +from cc2olx.models import Cartridge + +logger = logging.getLogger() + +Content = TypeVar("Content") + + +class StaticLinkProcessor: + """ + Provide static links processing functionality. + """ + + def __init__(self, cartridge: Cartridge) -> None: + self._cartridge = cartridge + + def process_content_static_links(self, content: Content) -> Content: + """ + Take a node data and recursively find and escape static links. + + Provide detail data with static link escaped to an OLX-friendly format. + """ + + if isinstance(content, str): + return self.process_static_links(content) + + if isinstance(content, list): + for index, value in enumerate(content): + content[index] = self.process_content_static_links(value) + elif isinstance(content, dict): + for key, value in content.items(): + content[key] = self.process_content_static_links(value) + + return content + + def process_static_links(self, html: str) -> str: + """ + Process static links like src and href to have appropriate links. + """ + items = re.findall(r'(src|href)\s*=\s*"(.+?)"', html) + + link_keyword_processors = ( + LinkKeywordProcessor("IMS-CC-FILEBASE", self._process_ims_cc_filebase), + LinkKeywordProcessor("WIKI_REFERENCE", self._process_wiki_reference), + LinkKeywordProcessor("external_tools", self._process_external_tools_link), + LinkKeywordProcessor("CANVAS_OBJECT_REFERENCE", self._process_canvas_reference), + ) + + for _, link in items: + for keyword, processor in link_keyword_processors: + if keyword in link: + html = processor(link, html) + break + + return html + + def _process_wiki_reference(self, link: str, html: str) -> str: + """ + Replace $WIKI_REFERENCE$ with edx /jump_to_id/. + """ + search_key = urllib.parse.unquote(link).replace("$WIKI_REFERENCE$/pages/", "") + + # remove query params and add suffix .html to match with resource_id_by_href + search_key = search_key.split("?")[0] + ".html" + for key in self._cartridge.resource_id_by_href.keys(): + if key.endswith(search_key): + replace_with = "/jump_to_id/{}".format(self._cartridge.resource_id_by_href[key]) + html = html.replace(link, replace_with) + return html + logger.warning("Unable to process Wiki link - %s", link) + return html + + @staticmethod + def _process_canvas_reference(link: str, html: str) -> str: + """ + Replace $CANVAS_OBJECT_REFERENCE$ with edx /jump_to_id/. + """ + object_id = urllib.parse.unquote(link).replace("$CANVAS_OBJECT_REFERENCE$/quizzes/", "/jump_to_id/") + html = html.replace(link, object_id) + return html + + @staticmethod + def _process_ims_cc_filebase(link: str, html: str) -> str: + """ + Replace $IMS-CC-FILEBASE$ with /static. + """ + new_link = urllib.parse.unquote(link).replace("$IMS-CC-FILEBASE$", "/static") + # skip query parameters for static files + new_link = new_link.split("?")[0] + # & is not valid in an URL. But some file seem to have it when it should be & + new_link = new_link.replace("&", "&") + html = html.replace(link, new_link) + return html + + @staticmethod + def _process_external_tools_link(link: str, html: str) -> str: + """ + Replace $CANVAS_OBJECT_REFERENCE$/external_tools/retrieve with appropriate external link. + """ + external_tool_query = urllib.parse.urlparse(link).query + # unescape query that has been HTML encoded so it can be parsed correctly + unescaped_external_tool_query = html_parser.unescape(external_tool_query) + external_tool_url = urllib.parse.parse_qs(unescaped_external_tool_query).get("url", [""])[0] + html = html.replace(link, external_tool_url) + return html diff --git a/src/cc2olx/content_parsers/video.py b/src/cc2olx/content_parsers/video.py new file mode 100644 index 0000000..e5f8b07 --- /dev/null +++ b/src/cc2olx/content_parsers/video.py @@ -0,0 +1,22 @@ +import re +from typing import Dict, Optional + +from cc2olx.constants import YOUTUBE_LINK_PATTERN +from cc2olx.content_parsers import AbstractContentParser +from cc2olx.content_parsers.mixins import WebLinkParserMixin + + +class VideoContentParser(WebLinkParserMixin, AbstractContentParser): + """ + Video resource content parser. + """ + + def _parse_content(self, idref: Optional[str]) -> Optional[Dict[str, str]]: + if ( + idref + and (resource := self._cartridge.define_resource(idref)) + and (web_link_content := self._parse_web_link_content(resource)) + and (youtube_match := re.search(YOUTUBE_LINK_PATTERN, web_link_content["href"])) + ): + return {"youtube": youtube_match.group("video_id")} + return None diff --git a/src/cc2olx/content_processors.py b/src/cc2olx/content_processors.py new file mode 100644 index 0000000..f8ce1bf --- /dev/null +++ b/src/cc2olx/content_processors.py @@ -0,0 +1,86 @@ +import xml.dom.minidom +from typing import List, Optional, Type, Union + +from cc2olx import content_parsers, olx_generators +from cc2olx.dataclasses import OlxGeneratorContext +from cc2olx.models import Cartridge + + +class AbstractContentProcessor: + """ + Abstract base class for Common Cartridge content processing. + """ + + content_parser_class: Type[content_parsers.AbstractContentParser] + olx_generator_class: Type[olx_generators.AbstractOlxGenerator] + + def __init__(self, cartridge: Cartridge, context: OlxGeneratorContext) -> None: + self._cartridge = cartridge + self._context = context + + def process(self, idref: Optional[str]) -> Optional[List[xml.dom.minidom.Element]]: + """ + Process a Common Cartridge resource content. + """ + parser = self.content_parser_class(self._cartridge) + if content := parser.parse(idref): + self._pre_olx_generation(content) + olx_generator = self.olx_generator_class(self._context) + return olx_generator.create_nodes(content) + return None + + def _pre_olx_generation(self, content: Union[list, dict]) -> None: + """ + The hook for actions performing before OLX generation. + """ + + +class HtmlContentProcessor(AbstractContentProcessor): + """ + HTML content processor. + """ + + content_parser_class = content_parsers.HtmlContentParser + olx_generator_class = olx_generators.HtmlOlxGenerator + + +class VideoContentProcessor(AbstractContentProcessor): + """ + Video content processor. + """ + + content_parser_class = content_parsers.VideoContentParser + olx_generator_class = olx_generators.VideoOlxGenerator + + +class LtiContentProcessor(AbstractContentProcessor): + """ + LTI content processor. + """ + + content_parser_class = content_parsers.LtiContentParser + olx_generator_class = olx_generators.LtiOlxGenerator + + def _pre_olx_generation(self, content: dict) -> None: + """ + Populate LTI consumer IDs with the resource LTI ID. + """ + self._context.add_lti_consumer_id(content["lti_id"]) + + +class QtiContentProcessor(AbstractContentProcessor): + """ + QTI content processor. + """ + + content_parser_class = content_parsers.QtiContentParser + olx_generator_class = olx_generators.QtiOlxGenerator + + +class DiscussionContentProcessor(AbstractContentProcessor): + """ + Discussion content processor. + """ + + content_parser_class = content_parsers.DiscussionContentParser + olx_generator_class = olx_generators.DiscussionOlxGenerator diff --git a/src/cc2olx/dataclasses.py b/src/cc2olx/dataclasses.py new file mode 100644 index 0000000..dbbcb1d --- /dev/null +++ b/src/cc2olx/dataclasses.py @@ -0,0 +1,39 @@ +from typing import Callable, List, NamedTuple, Set + +import attrs + +from cc2olx.iframe_link_parser import IframeLinkParser + + +class LinkKeywordProcessor(NamedTuple): + """ + Encapsulate a link keyword and it's processor. + """ + + keyword: str + processor: Callable[[str, str], str] + + +class FibProblemRawAnswers(NamedTuple): + """ + Encapsulate answers data for a Fill-In-The-Blank problem. + """ + + exact_answers: List[str] + answer_patterns: List[str] + + +@attrs.define(frozen=True) +class OlxGeneratorContext: + """ + Encapsulate an OLX generator context. + """ + + iframe_link_parser: IframeLinkParser + _lti_consumer_ids: Set[str] + + def add_lti_consumer_id(self, lti_consumer_id: str) -> None: + """ + Populate LTI consumer IDs set with a provided value. + """ + self._lti_consumer_ids.add(lti_consumer_id) diff --git a/src/cc2olx/enums.py b/src/cc2olx/enums.py new file mode 100644 index 0000000..7cc762b --- /dev/null +++ b/src/cc2olx/enums.py @@ -0,0 +1,28 @@ +from enum import Enum + + +class CommonCartridgeResourceType(str, Enum): + """ + Enumerate Common Cartridge resource types. + + Contain the exact type values and regular expressions to match the type. + """ + + WEB_CONTENT = "webcontent" + WEB_LINK = r"^imswl_xmlv(?P\d+)+p(?P\d+)$" + LTI_LINK = r"^imsbasiclti_xmlv\d+p\d+$" + QTI_ASSESSMENT = r"^imsqti_xmlv\d+p\d+/imscc_xmlv\d+p\d+/assessment$" + DISCUSSION_TOPIC = r"^imsdt_xmlv\d+p\d+$" + + +class QtiQuestionType(str, Enum): + """ + Enumerate QTI question types. + """ + + MULTIPLE_CHOICE = "cc.multiple_choice.v0p1" + MULTIPLE_RESPONSE = "cc.multiple_response.v0p1" + FILL_IN_THE_BLANK = "cc.fib.v0p1" + ESSAY = "cc.essay.v0p1" + BOOLEAN = "cc.true_false.v0p1" + PATTERN_MATCH = "cc.pattern_match.v0p1" diff --git a/src/cc2olx/exceptions.py b/src/cc2olx/exceptions.py new file mode 100644 index 0000000..7aae35e --- /dev/null +++ b/src/cc2olx/exceptions.py @@ -0,0 +1,4 @@ +class QtiError(Exception): + """ + Exception type for QTI parsing/conversion errors. + """ diff --git a/src/cc2olx/olx_generators/__init__.py b/src/cc2olx/olx_generators/__init__.py new file mode 100644 index 0000000..99a2fd6 --- /dev/null +++ b/src/cc2olx/olx_generators/__init__.py @@ -0,0 +1,6 @@ +from cc2olx.olx_generators.abc import AbstractOlxGenerator +from cc2olx.olx_generators.discussion import DiscussionOlxGenerator +from cc2olx.olx_generators.html import HtmlOlxGenerator +from cc2olx.olx_generators.lti import LtiOlxGenerator +from cc2olx.olx_generators.qti import QtiOlxGenerator +from cc2olx.olx_generators.video import VideoOlxGenerator diff --git a/src/cc2olx/olx_generators/abc.py b/src/cc2olx/olx_generators/abc.py new file mode 100644 index 0000000..79242d1 --- /dev/null +++ b/src/cc2olx/olx_generators/abc.py @@ -0,0 +1,21 @@ +import xml.dom.minidom +from abc import ABC, abstractmethod +from typing import List, Union + +from cc2olx.dataclasses import OlxGeneratorContext + + +class AbstractOlxGenerator(ABC): + """ + Abstract base class for OLX generation for Common Cartridge content. + """ + + def __init__(self, context: OlxGeneratorContext) -> None: + self._doc = xml.dom.minidom.Document() + self._context = context + + @abstractmethod + def create_nodes(self, content: Union[dict, List[dict]]) -> List[xml.dom.minidom.Element]: + """ + Create OLX nodes. + """ diff --git a/src/cc2olx/olx_generators/discussion.py b/src/cc2olx/olx_generators/discussion.py new file mode 100644 index 0000000..98a5148 --- /dev/null +++ b/src/cc2olx/olx_generators/discussion.py @@ -0,0 +1,31 @@ +import xml.dom.minidom +from typing import List + +from cc2olx.olx_generators import AbstractOlxGenerator +from cc2olx.utils import element_builder + + +class DiscussionOlxGenerator(AbstractOlxGenerator): + """ + Generate OLX for discussions. + """ + + DEFAULT_TEXT = "MISSING CONTENT" + + def create_nodes(self, content: dict) -> List[xml.dom.minidom.Element]: + el = element_builder(self._doc) + + txt = self.DEFAULT_TEXT if content["text"] is None else content["text"] + html_node = el("html", [self._doc.createCDATASection(txt)], {}) + + discussion_node = el( + "discussion", + [], + { + "display_name": "", + "discussion_category": content["title"], + "discussion_target": content["title"], + } + ) + + return [html_node, discussion_node] diff --git a/src/cc2olx/olx_generators/html.py b/src/cc2olx/olx_generators/html.py new file mode 100644 index 0000000..18ecb8b --- /dev/null +++ b/src/cc2olx/olx_generators/html.py @@ -0,0 +1,58 @@ +import xml.dom.minidom +from typing import List, Tuple + +import lxml.html + +from cc2olx.olx_generators import AbstractOlxGenerator + + +class HtmlOlxGenerator(AbstractOlxGenerator): + """ + Generate OLX for HTML content. + """ + + def create_nodes(self, content: dict) -> List[xml.dom.minidom.Element]: + """ + Process the HTML and gives out corresponding HTML or Video OLX nodes. + """ + video_olx = [] + nodes = [] + html = content["html"] + if self._context.iframe_link_parser: + html, video_olx = self._process_html_for_iframe(html) + txt = self._doc.createCDATASection(html) + + html_node = self._doc.createElement("html") + html_node.appendChild(txt) + nodes.append(html_node) + + nodes.extend(video_olx) + + return nodes + + def _process_html_for_iframe(self, html_str: str) -> Tuple[str, List[xml.dom.minidom.Element]]: + """ + Parse the iframe with embedded video, to be converted into video xblock. + + Provide the html content of the file, if iframe is present and + converted into xblock then iframe is removed from the HTML, as well as + a list of XML children, i.e video xblock. + """ + video_olx = [] + parsed_html = lxml.html.fromstring(html_str) + iframes = parsed_html.xpath("//iframe") + if not iframes: + return html_str, video_olx + + video_olx, converted_iframes = self._context.iframe_link_parser.get_video_olx(self._doc, iframes) + if video_olx: + # If video xblock is present then we modify the HTML to remove the iframe + # hence we need to convert the modified HTML back to string. We also remove + # the parent if there are no other children. + for iframe in converted_iframes: + parent = iframe.getparent() + parent.remove(iframe) + if not parent.getchildren(): + parent.getparent().remove(parent) + return lxml.html.tostring(parsed_html).decode("utf-8"), video_olx + return html_str, video_olx diff --git a/src/cc2olx/olx_generators/lti.py b/src/cc2olx/olx_generators/lti.py new file mode 100644 index 0000000..281b4c1 --- /dev/null +++ b/src/cc2olx/olx_generators/lti.py @@ -0,0 +1,43 @@ +import xml.dom.minidom +from typing import List + +from cc2olx.olx_generators import AbstractOlxGenerator +from cc2olx.utils import element_builder + + +class LtiOlxGenerator(AbstractOlxGenerator): + """ + Generate OLX for LTIs. + """ + + def create_nodes(self, content: dict) -> List[xml.dom.minidom.Element]: + el = element_builder(self._doc) + + custom_parameters = "[{params}]".format( + params=", ".join( + [ + '"{key}={value}"'.format( + key=key, + value=value, + ) + for key, value in content["custom_parameters"].items() + ] + ), + ) + lti_consumer_node = el( + "lti_consumer", + [], + { + "custom_parameters": custom_parameters, + "description": content["description"], + "display_name": content["title"], + "inline_height": content["height"], + "inline_width": content["width"], + "launch_url": content["launch_url"], + "modal_height": content["height"], + "modal_width": content["width"], + "xblock-family": "xblock.v1", + "lti_id": content["lti_id"], + } + ) + return [lti_consumer_node] diff --git a/src/cc2olx/olx_generators/qti.py b/src/cc2olx/olx_generators/qti.py new file mode 100644 index 0000000..1d69bad --- /dev/null +++ b/src/cc2olx/olx_generators/qti.py @@ -0,0 +1,308 @@ +import urllib.parse +import xml.dom.minidom +from html import unescape +from typing import Callable, Collection, Dict, List, Tuple, Union + +from lxml import etree, html + +from cc2olx.constants import QTI_RESPROCESSING_TYPES +from cc2olx.enums import QtiQuestionType +from cc2olx.exceptions import QtiError +from cc2olx.olx_generators import AbstractOlxGenerator +from cc2olx.utils import element_builder + + +class QtiOlxGenerator(AbstractOlxGenerator): + """ + Generate OLX for QTIs. + """ + + FIB_PROBLEM_TEXTLINE_SIZE_BUFFER = 10 + + def create_nodes(self, content: List[dict]) -> List[xml.dom.minidom.Element]: + problems = [] + + for problem_data in content: + cc_profile = problem_data["cc_profile"] + create_problem = self._problem_creators_map.get(cc_profile) + + if create_problem is None: + raise QtiError('Unknown cc_profile: "{}"'.format(problem_data["cc_profile"])) + + problem = create_problem(problem_data) + + # sometimes we might want to have additional items from one CC item + if isinstance(problem, list) or isinstance(problem, tuple): + problems += problem + else: + problems.append(problem) + + return problems + + @property + def _problem_creators_map( + self, + ) -> Dict[ + QtiQuestionType, + Callable[[dict], Union[xml.dom.minidom.Element, Collection[xml.dom.minidom.Element]]], + ]: + """ + Provide CC profile value to actual problem node creators mapping. + + Note: Since True/False problems in OLX are constructed identically to + OLX Multiple Choice problems, we reuse `_create_multiple_choice_problem` + for BOOLEAN type problems + """ + return { + QtiQuestionType.MULTIPLE_CHOICE: self._create_multiple_choice_problem, + QtiQuestionType.MULTIPLE_RESPONSE: self._create_multiple_response_problem, + QtiQuestionType.FILL_IN_THE_BLANK: self._create_fib_problem, + QtiQuestionType.ESSAY: self._create_essay_problem, + QtiQuestionType.BOOLEAN: self._create_multiple_choice_problem, + QtiQuestionType.PATTERN_MATCH: self._create_pattern_match_problem, + } + + @staticmethod + def _create_problem_description(description_html_str: str) -> xml.dom.minidom.Element: + """ + Create a problem description node. + + Material texts can come in form of escaped HTML markup, which + can't be considered as valid XML. ``xml.dom.minidom`` has no + features to convert HTML to XML, so we use lxml parser here. + """ + description_html_str = unescape(description_html_str) + + description_html_str = urllib.parse.unquote(description_html_str) + + element = html.fromstring(description_html_str) + xml_string = etree.tostring(element) + return xml.dom.minidom.parseString(xml_string).firstChild + + def _add_choice(self, parent: xml.dom.minidom.Element, is_correct: bool, text: str) -> None: + """ + Append choices to given ``checkboxgroup`` or ``choicegroup`` parent. + """ + choice = self._doc.createElement("choice") + choice.setAttribute("correct", "true" if is_correct else "false") + self._set_text(choice, text) + parent.appendChild(choice) + + def _set_text(self, node: xml.dom.minidom.Element, new_text: str) -> None: + """ + Set a node text. + """ + text_node = self._doc.createTextNode(new_text) + node.appendChild(text_node) + + def _create_multiple_choice_problem(self, problem_data: dict) -> xml.dom.minidom.Element: + """ + Create multiple choice problem OLX. + """ + problem = self._doc.createElement("problem") + problem_content = self._doc.createElement("multiplechoiceresponse") + + problem_description = self._create_problem_description(problem_data["problem_description"]) + + choice_group = self._doc.createElement("choicegroup") + choice_group.setAttribute("type", "MultipleChoice") + + for choice_data in problem_data["choices"].values(): + self._add_choice(choice_group, choice_data["correct"], choice_data["text"]) + + problem_content.appendChild(problem_description) + problem_content.appendChild(choice_group) + problem.appendChild(problem_content) + + return problem + + def _create_multiple_response_problem(self, problem_data: dict) -> xml.dom.minidom.Element: + """ + Create multiple response problem OLX. + + Set partial_credit to EDC by default. + """ + el = element_builder(self._doc) + + problem_description = self._create_problem_description(problem_data["problem_description"]) + + problem = el( + "problem", + [ + el( + "choiceresponse", + [ + problem_description, + el( + "checkboxgroup", + [ + el( + "choice", + choice["text"], + {"correct": "true" if choice["correct"] else "false"}, + ) + for choice in problem_data["choices"].values() + ], + {"type": "MultipleChoice"}, + ), + ], + {"partial_credit": "EDC"}, + ), + ], + ) + return problem + + def _create_fib_problem(self, problem_data: dict) -> xml.dom.minidom.Element: + """ + Create Fill-In-The-Blank problem OLX. + """ + # Track maximum answer length for textline at the bottom + max_answer_length = 0 + + problem = self._doc.createElement("problem") + + # Set the primary answer on the stringresponse + # and set the type to case insensitive + problem_content = self._doc.createElement("stringresponse") + problem_content.setAttribute("answer", problem_data["answer"]) + problem_content.setAttribute("type", self._build_fib_problem_type(problem_data)) + + if len(problem_data["answer"]) > max_answer_length: + max_answer_length = len(problem_data["answer"]) + + problem_description = self._create_problem_description(problem_data["problem_description"]) + problem_content.appendChild(problem_description) + + # For any (optional) additional accepted answers, add an + # additional_answer element with that answer + for answer in problem_data.get("additional_answers", []): + additional_answer = self._doc.createElement("additional_answer") + additional_answer.setAttribute("answer", answer) + problem_content.appendChild(additional_answer) + + if len(answer) > max_answer_length: + max_answer_length = len(answer) + + # Add a textline element with the max answer length plus a buffer + textline = self._doc.createElement("textline") + textline.setAttribute("size", str(max_answer_length + self.FIB_PROBLEM_TEXTLINE_SIZE_BUFFER)) + problem_content.appendChild(textline) + + problem.appendChild(problem_content) + + return problem + + @staticmethod + def _build_fib_problem_type(problem_data: dict) -> str: + """ + Build `stringresponse` OLX type for a Fill-In-The-Blank problem. + """ + problem_types = ["ci"] + + if problem_data["is_regexp"]: + problem_types.append("regexp") + + return " ".join(problem_types) + + def _create_essay_problem( + self, + problem_data: dict, + ) -> Union[xml.dom.minidom.Element, Tuple[xml.dom.minidom.Element, xml.dom.minidom.Element]]: + """ + Create an essay problem OLX. + + Given parsed essay problem data, returns a openassessment component. If a sample + solution provided, returns that as a HTML block before openassessment. + """ + el = element_builder(self._doc) + + if any(key in QTI_RESPROCESSING_TYPES for key in problem_data.keys()): + resp_samples = [ + el("name", "Feedback"), + el("label", "Feedback"), + el("prompt", "Example Feedback"), + ] + + for desc, key in zip(["General", "Correct", "Incorrect"], QTI_RESPROCESSING_TYPES): + resp_samples.append( + el( + "option", + [el("name", desc), el("label", desc), el("explanation", problem_data.get(key, desc))], + {"points": "0"}, + ) + ) + criterion = el("criterion", resp_samples, {"feedback": "optional"}) + else: + criterion = el( + "criterion", + [ + el("name", "Ideas"), + el("label", "Ideas"), + el("prompt", "Example criterion"), + el( + "option", + [el("name", "Poor"), el("label", "Poor"), el("explanation", "Explanation")], + {"points": "0"}, + ), + el( + "option", + [el("name", "Good"), el("label", "Good"), el("explanation", "Explanation")], + {"points": "1"}, + ), + ], + {"feedback": "optional"}, + ) + + description = problem_data["problem_description"] + ora = el( + "openassessment", + [ + el("title", "Open Response Assessment"), + el( + "assessments", + [ + el( + "assessment", + None, + attributes={"name": "staff-assessment", "required": "True"} + ), + ] + ), + el( + "prompts", + [ + el( + "prompt", + [el("description", description)], + ), + ], + ), + el( + "rubric", + [ + criterion, + el("feedbackprompt", "Feedback prompt text"), + el("feedback_default_text", "Feedback prompt default text"), + ], + ), + ], + { + "url_name": problem_data["ident"], + "text_response": "required", + "prompts_type": "html", + } + ) + + # if a sample solution exists add on top of ora, because + # olx doesn't have a sample solution equivalent. + if problem_data.get("sample_solution"): + child = el("html", self._doc.createCDATASection(problem_data["sample_solution"])) + return child, ora + + return ora + + def _create_pattern_match_problem(self, problem_data: dict) -> xml.dom.minidom.Element: + """ + Create pattern match problem OLX. + """ + raise NotImplementedError diff --git a/src/cc2olx/olx_generators/video.py b/src/cc2olx/olx_generators/video.py new file mode 100644 index 0000000..0b86fdd --- /dev/null +++ b/src/cc2olx/olx_generators/video.py @@ -0,0 +1,18 @@ +import xml.dom.minidom +from typing import List + +from cc2olx.olx_generators import AbstractOlxGenerator +from cc2olx.utils import element_builder + + +class VideoOlxGenerator(AbstractOlxGenerator): + """ + Generate OLX for video content. + """ + + def create_nodes(self, content: dict) -> List[xml.dom.minidom.Element]: + xml_element = element_builder(self._doc) + youtube_video_id = content["youtube"] + attributes = {"youtube": f"1.00:{youtube_video_id}", "youtube_id_1_0": content["youtube"]} + video_element = xml_element("video", children=None, attributes=attributes) + return [video_element] diff --git a/src/cc2olx/templates/external_webcontent.html b/src/cc2olx/templates/external_webcontent.html new file mode 100644 index 0000000..1f52cc6 --- /dev/null +++ b/src/cc2olx/templates/external_webcontent.html @@ -0,0 +1,10 @@ + + + + + +

+ {res_relative_path} +

+ + diff --git a/src/cc2olx/templates/image_webcontent.html b/src/cc2olx/templates/image_webcontent.html new file mode 100644 index 0000000..c55beeb --- /dev/null +++ b/src/cc2olx/templates/image_webcontent.html @@ -0,0 +1,10 @@ + + + + + +

+ {static_filename} +

+ +