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.

class QTITemplateConverter(quizcomp.converter.template.TemplateConverter):
 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. """
out_path: Optional[str]

The path to write the quiz conversion output file to.

def finalize(self, quiz: quizcomp.model.quiz.Quiz, text: str) -> str:
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.

def convert_quiz(self, quiz: quizcomp.model.quiz.Quiz, **kwargs: Any) -> str:
 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).