quizcomp.converter.qti
1import logging 2import os 3import shutil 4import typing 5import warnings 6 7import bs4 8import edq.util.dirent 9 10import quizcomp.converter.template 11import quizcomp.model.constants 12import quizcomp.model.quiz 13 14_logger = logging.getLogger(__name__) 15 16THIS_DIR: str = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) 17DEFAULT_TEMPLATE_DIR: str = os.path.join(THIS_DIR, '..', 'data', 'templates', 'edq-qti') 18 19TEMPLATE_FILENAME_ASSESSMENT_META: str = 'qti_assessment_meta.template' 20TEMPLATE_FILENAME_MANIFEST: str = 'qti_imsmanifest.template' 21 22OUT_DIR_IMAGES: str = 'images' 23OUT_DIR_QUIZ: str = 'quiz' 24OUT_FILENAME_QUIZ: str = 'quiz.xml' 25OUT_FILENAME_ASSESSMENT_META: str = 'assessment_meta.xml' 26OUT_FILENAME_MANIFEST: str = 'imsmanifest.xml' 27 28QUESTION_TYPE_MAP: typing.Dict[quizcomp.model.constants.QuestionType, str] = { 29 # Direct Mappings 30 quizcomp.model.constants.QuestionType.ESSAY: 'essay_question', 31 quizcomp.model.constants.QuestionType.FIMB: 'fill_in_multiple_blanks_question', 32 quizcomp.model.constants.QuestionType.MATCHING: 'matching_question', 33 quizcomp.model.constants.QuestionType.MA: 'multiple_answers_question', 34 quizcomp.model.constants.QuestionType.MCQ: 'multiple_choice_question', 35 quizcomp.model.constants.QuestionType.MDD: 'multiple_dropdowns_question', 36 quizcomp.model.constants.QuestionType.NUMERICAL: 'numerical_question', 37 quizcomp.model.constants.QuestionType.TEXT_ONLY: 'text_only_question', 38 quizcomp.model.constants.QuestionType.TF: 'true_false_question', 39 # Indirect Mappings 40 quizcomp.model.constants.QuestionType.FITB: 'short_answer_question', 41 quizcomp.model.constants.QuestionType.SA: 'essay_question', 42} 43""" QTI-specific question types. """ 44 45class QTITemplateConverter(quizcomp.converter.template.TemplateConverter): 46 """ 47 A converter to convert a quiz to QTI using templates. 48 """ 49 50 def __init__(self, 51 out_path: typing.Union[str, None] = None, 52 template_dir: str = DEFAULT_TEMPLATE_DIR, 53 **kwargs: typing.Any) -> None: 54 super().__init__(quizcomp.model.constants.Format.HTML, template_dir, 55 jinja_filters = { 56 'to_xml': _to_xml, 57 }, 58 jinja_globals = { 59 'question_type_map': QUESTION_TYPE_MAP, 60 }, 61 **kwargs) 62 63 self.out_path: typing.Union[str, None] = out_path 64 """ The path to write the quiz conversion output file to. """ 65 66 def finalize(self, quiz: quizcomp.model.quiz.Quiz, text: str) -> str: 67 self._restore_image_sources(quiz) 68 69 return _format_xml(text) 70 71 def convert_quiz(self, quiz: quizcomp.model.quiz.Quiz, **kwargs: typing.Any) -> str: 72 if (self.out_path is None): 73 out_path = f'{quiz.get_name()}.qti.zip' 74 else: 75 out_path = self.out_path 76 77 out_path = os.path.abspath(out_path) 78 79 temp_base_dir = edq.util.dirent.get_temp_dir(prefix = 'quizcomp-qti-') 80 81 temp_dir = os.path.join(temp_base_dir, quiz.get_name()) 82 83 images_dir = os.path.join(temp_dir, OUT_DIR_IMAGES) 84 edq.util.dirent.mkdir(images_dir) 85 self.image_base_dir = images_dir 86 self._store_images(quiz) 87 88 quiz_dir = os.path.join(temp_dir, OUT_DIR_QUIZ) 89 edq.util.dirent.mkdir(quiz_dir) 90 91 quiz_xml_path = os.path.join(quiz_dir, OUT_FILENAME_QUIZ) 92 text = super().convert_quiz(quiz, **kwargs) 93 edq.util.dirent.write_file(quiz_xml_path, text) 94 95 self._convert_assessment_meta(quiz, quiz_dir) 96 self._convert_manifest(quiz, temp_dir) 97 98 shutil.make_archive(os.path.splitext(out_path)[0], 'zip', temp_base_dir, quiz.get_name()) 99 100 _logger.info("Created QTI quiz at '%s'.", out_path) 101 return out_path 102 103 def _convert_assessment_meta(self, quiz: quizcomp.model.quiz.Quiz, out_dir: str) -> None: 104 """ Write a quiz's metadata. """ 105 106 template = self.env.get_template(TEMPLATE_FILENAME_ASSESSMENT_META) 107 108 context = { 109 'quiz': quiz, 110 } 111 112 text = template.render(**context) 113 text = _format_xml(text) 114 115 path = os.path.join(out_dir, OUT_FILENAME_ASSESSMENT_META) 116 edq.util.dirent.write_file(path, text) 117 118 def _convert_manifest(self, quiz: quizcomp.model.quiz.Quiz, out_dir: str) -> None: 119 """ Write the manifest for the quiz. """ 120 121 template = self.env.get_template(TEMPLATE_FILENAME_MANIFEST) 122 123 files = [] 124 for filename in os.listdir(self.image_base_dir): 125 files.append({ 126 'type': 'image', 127 'id': os.path.splitext(filename)[0], 128 'path': '/'.join([quiz.get_name(), OUT_DIR_IMAGES, filename]), 129 'filename': filename, 130 }) 131 132 context: typing.Dict[str, typing.Any] = { 133 'quiz': quiz, 134 'files': files, 135 } 136 137 text = template.render(**context) 138 text = _format_xml(text) 139 140 path = os.path.join(out_dir, OUT_FILENAME_MANIFEST) 141 edq.util.dirent.write_file(path, text) 142 143 def _form_image_source(self, filename: str, quiz: quizcomp.model.quiz.Quiz) -> str: 144 return '/'.join(['$IMS-CC-FILEBASE$', quiz.get_name(), OUT_DIR_IMAGES, filename]) 145 146def _format_xml(text: str) -> str: 147 """ Format/Prettify the XML. """ 148 149 warnings.filterwarnings('ignore', category = bs4.builder.XMLParsedAsHTMLWarning) # type: ignore[attr-defined] 150 document = bs4.BeautifulSoup(text, 'html.parser') 151 return document.prettify(formatter = bs4.formatter.HTMLFormatter(indent = 4)) 152 153def _to_xml(item: typing.Union[None, bool, typing.Any]) -> str: 154 """ 155 Convert the item to an XML string. 156 """ 157 158 if (item is None): 159 return '' 160 161 if (isinstance(item, bool)): 162 return str(item).lower() 163 164 return str(item)
THIS_DIR: str =
'/home/runner/work/quiz-composer/quiz-composer/quizcomp/converter'
DEFAULT_TEMPLATE_DIR: str =
'/home/runner/work/quiz-composer/quiz-composer/quizcomp/converter/../data/templates/edq-qti'
TEMPLATE_FILENAME_ASSESSMENT_META: str =
'qti_assessment_meta.template'
TEMPLATE_FILENAME_MANIFEST: str =
'qti_imsmanifest.template'
OUT_DIR_IMAGES: str =
'images'
OUT_DIR_QUIZ: str =
'quiz'
OUT_FILENAME_QUIZ: str =
'quiz.xml'
OUT_FILENAME_ASSESSMENT_META: str =
'assessment_meta.xml'
OUT_FILENAME_MANIFEST: str =
'imsmanifest.xml'
QUESTION_TYPE_MAP: Dict[quizcomp.model.constants.QuestionType, str] =
{<QuestionType.ESSAY: 'essay'>: 'essay_question', <QuestionType.FIMB: 'fill_in_multiple_blanks'>: 'fill_in_multiple_blanks_question', <QuestionType.MATCHING: 'matching'>: 'matching_question', <QuestionType.MA: 'multiple_answers'>: 'multiple_answers_question', <QuestionType.MCQ: 'multiple_choice'>: 'multiple_choice_question', <QuestionType.MDD: 'multiple_dropdowns'>: 'multiple_dropdowns_question', <QuestionType.NUMERICAL: 'numerical'>: 'numerical_question', <QuestionType.TEXT_ONLY: 'text_only'>: 'text_only_question', <QuestionType.TF: 'true_false'>: 'true_false_question', <QuestionType.FITB: 'fill_in_the_blank'>: 'short_answer_question', <QuestionType.SA: 'short_answer'>: 'essay_question'}
QTI-specific question types.
46class QTITemplateConverter(quizcomp.converter.template.TemplateConverter): 47 """ 48 A converter to convert a quiz to QTI using templates. 49 """ 50 51 def __init__(self, 52 out_path: typing.Union[str, None] = None, 53 template_dir: str = DEFAULT_TEMPLATE_DIR, 54 **kwargs: typing.Any) -> None: 55 super().__init__(quizcomp.model.constants.Format.HTML, template_dir, 56 jinja_filters = { 57 'to_xml': _to_xml, 58 }, 59 jinja_globals = { 60 'question_type_map': QUESTION_TYPE_MAP, 61 }, 62 **kwargs) 63 64 self.out_path: typing.Union[str, None] = out_path 65 """ The path to write the quiz conversion output file to. """ 66 67 def finalize(self, quiz: quizcomp.model.quiz.Quiz, text: str) -> str: 68 self._restore_image_sources(quiz) 69 70 return _format_xml(text) 71 72 def convert_quiz(self, quiz: quizcomp.model.quiz.Quiz, **kwargs: typing.Any) -> str: 73 if (self.out_path is None): 74 out_path = f'{quiz.get_name()}.qti.zip' 75 else: 76 out_path = self.out_path 77 78 out_path = os.path.abspath(out_path) 79 80 temp_base_dir = edq.util.dirent.get_temp_dir(prefix = 'quizcomp-qti-') 81 82 temp_dir = os.path.join(temp_base_dir, quiz.get_name()) 83 84 images_dir = os.path.join(temp_dir, OUT_DIR_IMAGES) 85 edq.util.dirent.mkdir(images_dir) 86 self.image_base_dir = images_dir 87 self._store_images(quiz) 88 89 quiz_dir = os.path.join(temp_dir, OUT_DIR_QUIZ) 90 edq.util.dirent.mkdir(quiz_dir) 91 92 quiz_xml_path = os.path.join(quiz_dir, OUT_FILENAME_QUIZ) 93 text = super().convert_quiz(quiz, **kwargs) 94 edq.util.dirent.write_file(quiz_xml_path, text) 95 96 self._convert_assessment_meta(quiz, quiz_dir) 97 self._convert_manifest(quiz, temp_dir) 98 99 shutil.make_archive(os.path.splitext(out_path)[0], 'zip', temp_base_dir, quiz.get_name()) 100 101 _logger.info("Created QTI quiz at '%s'.", out_path) 102 return out_path 103 104 def _convert_assessment_meta(self, quiz: quizcomp.model.quiz.Quiz, out_dir: str) -> None: 105 """ Write a quiz's metadata. """ 106 107 template = self.env.get_template(TEMPLATE_FILENAME_ASSESSMENT_META) 108 109 context = { 110 'quiz': quiz, 111 } 112 113 text = template.render(**context) 114 text = _format_xml(text) 115 116 path = os.path.join(out_dir, OUT_FILENAME_ASSESSMENT_META) 117 edq.util.dirent.write_file(path, text) 118 119 def _convert_manifest(self, quiz: quizcomp.model.quiz.Quiz, out_dir: str) -> None: 120 """ Write the manifest for the quiz. """ 121 122 template = self.env.get_template(TEMPLATE_FILENAME_MANIFEST) 123 124 files = [] 125 for filename in os.listdir(self.image_base_dir): 126 files.append({ 127 'type': 'image', 128 'id': os.path.splitext(filename)[0], 129 'path': '/'.join([quiz.get_name(), OUT_DIR_IMAGES, filename]), 130 'filename': filename, 131 }) 132 133 context: typing.Dict[str, typing.Any] = { 134 'quiz': quiz, 135 'files': files, 136 } 137 138 text = template.render(**context) 139 text = _format_xml(text) 140 141 path = os.path.join(out_dir, OUT_FILENAME_MANIFEST) 142 edq.util.dirent.write_file(path, text) 143 144 def _form_image_source(self, filename: str, quiz: quizcomp.model.quiz.Quiz) -> str: 145 return '/'.join(['$IMS-CC-FILEBASE$', quiz.get_name(), OUT_DIR_IMAGES, filename])
A converter to convert a quiz to QTI using templates.
QTITemplateConverter( out_path: Optional[str] = None, template_dir: str = '/home/runner/work/quiz-composer/quiz-composer/quizcomp/converter/../data/templates/edq-qti', **kwargs: Any)
51 def __init__(self, 52 out_path: typing.Union[str, None] = None, 53 template_dir: str = DEFAULT_TEMPLATE_DIR, 54 **kwargs: typing.Any) -> None: 55 super().__init__(quizcomp.model.constants.Format.HTML, template_dir, 56 jinja_filters = { 57 'to_xml': _to_xml, 58 }, 59 jinja_globals = { 60 'question_type_map': QUESTION_TYPE_MAP, 61 }, 62 **kwargs) 63 64 self.out_path: typing.Union[str, None] = out_path 65 """ The path to write the quiz conversion output file to. """
67 def finalize(self, quiz: quizcomp.model.quiz.Quiz, text: str) -> str: 68 self._restore_image_sources(quiz) 69 70 return _format_xml(text)
A final chance for children to modify the output.
72 def convert_quiz(self, quiz: quizcomp.model.quiz.Quiz, **kwargs: typing.Any) -> str: 73 if (self.out_path is None): 74 out_path = f'{quiz.get_name()}.qti.zip' 75 else: 76 out_path = self.out_path 77 78 out_path = os.path.abspath(out_path) 79 80 temp_base_dir = edq.util.dirent.get_temp_dir(prefix = 'quizcomp-qti-') 81 82 temp_dir = os.path.join(temp_base_dir, quiz.get_name()) 83 84 images_dir = os.path.join(temp_dir, OUT_DIR_IMAGES) 85 edq.util.dirent.mkdir(images_dir) 86 self.image_base_dir = images_dir 87 self._store_images(quiz) 88 89 quiz_dir = os.path.join(temp_dir, OUT_DIR_QUIZ) 90 edq.util.dirent.mkdir(quiz_dir) 91 92 quiz_xml_path = os.path.join(quiz_dir, OUT_FILENAME_QUIZ) 93 text = super().convert_quiz(quiz, **kwargs) 94 edq.util.dirent.write_file(quiz_xml_path, text) 95 96 self._convert_assessment_meta(quiz, quiz_dir) 97 self._convert_manifest(quiz, temp_dir) 98 99 shutil.make_archive(os.path.splitext(out_path)[0], 'zip', temp_base_dir, quiz.get_name()) 100 101 _logger.info("Created QTI quiz at '%s'.", out_path) 102 return out_path
Convert an entire quiz (including variants).