quizcomp.model.quiz

  1import enum
  2import logging
  3import os
  4import random
  5import string
  6import typing
  7
  8import edq.util.dirent
  9import edq.util.enum
 10import edq.util.git
 11import edq.util.time
 12
 13import quizcomp.model.base
 14import quizcomp.model.constants
 15import quizcomp.model.errors
 16import quizcomp.model.group
 17import quizcomp.model.question
 18import quizcomp.parser.document
 19
 20_logger = logging.getLogger(__name__)
 21
 22DUMMY_QUIZ_DATA: typing.Dict[str, typing.Any] = {
 23    'name': 'Dummy Name',
 24    'description': quizcomp.parser.document.ParsedDocument.parse_text('Dummy description.'),
 25    'course_name': 'Dummy Course',
 26    'term_name': 'Dummy Term',
 27    'version': '0.0.0',
 28    'shuffle_answers': False,
 29}
 30
 31DUMMY_GROUP_DATA: typing.Dict[str, typing.Any] = {
 32    'name': 'Dummy Question',
 33}
 34
 35DEFAULT_VARIANT_IDS: typing.List[str] = list(string.ascii_uppercase)
 36""" Default IDs for quiz variants. """
 37
 38DEFAULT_MAX_VARIANTS: int = len(DEFAULT_VARIANT_IDS)
 39
 40class HideResultsBehavior(enum.Enum):
 41    """
 42    The allowed behaviors for hiding results from students for a quiz with multiple attempts.
 43    """
 44
 45    ALWASY_HIDE = 'always'
 46    """ Students can never see their results. """
 47
 48    NEVER_HIDE = 'never'
 49    """ Students can see their results after each attempt. """
 50
 51    UNTIL_AFTER_LAST_ATTEMPT = 'until_after_last_attempt'
 52    """ Students can see their results after each attempt. """
 53
 54class ScoringPolicy(enum.Enum):
 55    """
 56    The allowed scoring policies for quizzes with multiple attempts.
 57    """
 58
 59    KEEP_HIGHEST = 'keep_highest'
 60    """ Keep the highest score from all attempts. """
 61
 62    KEEP_LATEST = 'keep_latest'
 63    """ Keep the most recent score from all attempts. """
 64
 65class Quiz(quizcomp.model.base.CoreType):
 66    """
 67    A quiz object represents multiple possible assessments (called "variants").
 68    """
 69
 70    def __init__(self,
 71            children: typing.Union[typing.List[quizcomp.model.group.Group], None] = None,
 72            description: typing.Union[quizcomp.parser.document.ParsedDocument, str, None] = None,
 73            course_name: typing.Union[str, None] = None,
 74            term_name: typing.Union[str, None] = None,
 75            date: typing.Union[edq.util.time.Timestamp, None] = None,
 76            time_limit_mins: typing.Union[int, None] = None,
 77            version: typing.Union[str, None] = None,
 78            practice: typing.Union[bool, None] = None,
 79            publish: typing.Union[bool, None] = None,
 80            assignment_group: typing.Union[str, None] = None,
 81            allowed_attempts: typing.Union[int, None] = None,
 82            show_correct_answers: typing.Union[bool, None] = None,
 83            hide_results: typing.Union[HideResultsBehavior, str, None] = None,
 84            scoring_policy: typing.Union[ScoringPolicy, str, None] = None,
 85            **kwargs: typing.Any) -> None:
 86        # Remove aliases before super construction.
 87        kwargs.pop('groups', None)
 88
 89        super().__init__(children = children, **kwargs)
 90
 91        self.course_name: typing.Union[str, None] = course_name
 92        """ The optional name for the course associated with this quiz. """
 93
 94        self.term_name: typing.Union[str, None] = term_name
 95        """ The optional name of the term this quiz takes place during (e.g., "Fall 20XX"). """
 96
 97        self.date: typing.Union[edq.util.time.Timestamp, None] = date
 98        """ The optional date of this quiz. """
 99
100        if (description is None):
101            description = quizcomp.parser.document.ParsedDocument()
102
103        if (isinstance(description, str)):
104            description = quizcomp.parser.document.ParsedDocument.parse_text(description)
105
106        self.description: quizcomp.parser.document.ParsedDocument = description
107        """ The description/prompt for this quiz. """
108
109        if ((time_limit_mins is not None) and (time_limit_mins < 0)):
110            time_limit_mins = None
111
112        self.time_limit_mins: typing.Union[int, None] = time_limit_mins
113        """ The time limit (in minutes) for this quiz. """
114
115        self.version: typing.Union[str, None] = version
116        """ The version of this quiz. """
117
118        self.practice: typing.Union[bool, None] = practice
119        """
120        Whether this quiz should be considered a "practice" quiz.
121        This may change the behavior of different quizzes when uploaded to different platforms.
122        """
123
124        self.publish: typing.Union[bool, None] = publish
125        """
126        Whether this quiz should be considered published on upload.
127        "Published" quizzes are typically visible to students after upload.
128        """
129
130        self.assignment_group: typing.Union[str, None] = assignment_group
131        """
132        The name of the assignment group that this quiz should be uploaded under.
133        Unnecessary if this quiz is not uploaded.
134        """
135
136        self.allowed_attempts: typing.Union[int, None] = allowed_attempts
137        """ The number of attempts a student should have when taking this quiz. """
138
139        self.show_correct_answers: typing.Union[bool, None] = show_correct_answers
140        """ Show students the correct answer after submission. """
141
142        if (isinstance(hide_results, str)):
143            if (not edq.util.enum.has_value(HideResultsBehavior, hide_results)):
144                _logger.warning("Unknown enum value for 'hide_results': '%s'. Setting to null.", hide_results)
145                hide_results = None
146            else:
147                hide_results = HideResultsBehavior(hide_results)
148
149        self.hide_results: typing.Union[HideResultsBehavior, None] = hide_results
150        """ The behavior for showing results to students when multiple attempts are allowed. """
151
152        if (isinstance(scoring_policy, str)):
153            if (not edq.util.enum.has_value(ScoringPolicy, scoring_policy)):
154                _logger.warning("Unknown enum value for 'scoring_policy': '%s'. Setting to null.", scoring_policy)
155                scoring_policy = None
156            else:
157                scoring_policy = ScoringPolicy(scoring_policy)
158
159        self.scoring_policy: typing.Union[ScoringPolicy, None] = scoring_policy
160        """ The scoring behavior when multiple attempts are allowed. """
161
162        self._validate()
163
164    def _validate(self) -> None:
165        """ Check if this quiz is valid. """
166
167        if (self.name is None):
168            raise quizcomp.model.errors.QuizValidationError("Quiz name cannot be empty.", context = self)
169
170    def get_groups(self) -> typing.List[quizcomp.model.group.Group]:
171        """ Get all groups for this quiz. """
172
173        return [typing.cast(quizcomp.model.group.Group, child) for child in self.children]
174
175    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
176        return [self.description]
177
178    @classmethod
179    def prep_init_data(cls,
180            data: typing.Dict[str, typing.Any],
181            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
182            ) -> typing.Dict[str, typing.Any]:
183        if (context is None):
184            context = edq.util.serial.SerializationContext()
185
186        data = super().prep_init_data(data, context)
187
188        data['description'] = cls._collect_description(data, context)
189
190        return data
191
192    @classmethod
193    def _collect_description(cls,
194            data: typing.Dict[str, typing.Any],
195            context: edq.util.serial.SerializationContext,
196            ) -> quizcomp.parser.document.ParsedDocument:
197        """
198        Collect the description from one of several possible locations.
199
200        The description is allowed to appear (in order of priority):
201        1) in the `description` field.
202        2) pointed to by the `description_path` field.
203        3) or be in the same path as the quiz JSON, but with an `.md` extension
204           (e.g., `a/b/my_quiz.json` and `a/b/my_quiz.md`).
205
206        None values will be ignored (but empty values are valid).
207        Will return an empty description if none of these are present.
208        """
209
210        # If we have a quiz path, use that to resolve paths.
211        default_description_path = None
212        if (context.source_path is not None):
213            context.source_path = os.path.abspath(context.source_path)
214            context.base_dir = os.path.dirname(context.source_path)
215            default_description_path = os.path.splitext(context.source_path)[0] + '.md'
216
217        # Check the `description` field.
218        text = data.get('description', None)
219        if (text is not None):
220            return quizcomp.parser.document.ParsedDocument.parse_text(text, context)
221
222        # Check for an explicitly provided path.
223        description_path = data.get('description_path', None)
224        if (description_path is not None):
225            if (not os.path.isabs(description_path)):
226                description_path = os.path.join(context.base_dir, description_path)
227
228            description_path = os.path.abspath(description_path)
229
230            if (not os.path.isfile(description_path)):
231                raise quizcomp.model.errors.QuestionValidationError(
232                        f"Could not find a description at the provided path: '{data['description_path']}' (Absolute Path: '{description_path}').",
233                        context = context)
234
235            return quizcomp.parser.document.ParsedDocument.parse_file(description_path)
236
237        # Check for an implicit path.
238        if ((default_description_path is not None) and os.path.isfile(default_description_path)):
239            return quizcomp.parser.document.ParsedDocument.parse_file(default_description_path)
240
241        return quizcomp.parser.document.ParsedDocument()
242
243    def to_dict(self,
244            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
245            ) -> typing.Dict[str, edq.util.serial.PODType]:
246        data = super().to_dict(context)
247        data['groups'] = data.pop('children', data.get('groups', None))
248        return data
249
250    @classmethod
251    def from_dict(cls,
252            data: typing.Dict[str, edq.util.serial.PODType],
253            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
254            ) -> 'Quiz':
255        data['children'] = data.pop('groups', data.get('children', None))
256        return super().from_dict(data, context)
257
258    def create_variant(self,
259            seed: typing.Union[int, None] = None,
260            identifiers: typing.Union[typing.List[str], None] = None,
261            all_questions: bool = False,
262            include_solo_identifier: bool = False,
263            ) -> 'Variant':
264        """ A convenience call to create_variants(). """
265
266        return self.create_variants(
267            count = 1,
268            seed = seed,
269            identifiers = identifiers,
270            all_questions = all_questions, include_solo_identifier = include_solo_identifier,
271        )[0]
272
273    def create_variants(self,
274            count: int = 1,
275            seed: typing.Union[int, None] = None,
276            identifiers: typing.Union[typing.List[str], None] = None,
277            all_questions: bool = False,
278            include_solo_identifier: bool = False,
279            ) -> typing.List['Variant']:
280        """
281        Create a collection of variants based on this quiz.
282        These variants will share the same question pool,
283        which is influenced by the `pick_with_replacement` config option.
284
285        Setting `include_solo_identifier` to true will include an identifier (e.g., " - A")
286        in the name of variants when only one variant is created.
287        """
288
289        if (seed is None):
290            seed = random.randint(0, 2**64)
291
292        rng = random.Random(seed)
293
294        if (identifiers is None):
295            identifiers = DEFAULT_VARIANT_IDS
296
297        if (count < 0):
298            raise quizcomp.model.errors.QuizValidationError(
299                    f"Variant count must be non-negative, found: {count}.",
300                    context = self)
301
302        if (count > len(identifiers)):
303            raise quizcomp.model.errors.QuizValidationError(
304                ('"Not enough variant identifiers supplied.'
305                    + f" Got {len(identifiers)} identifiers and {count} requested variants."
306                    + f" Given identifiers: {identifiers}."),
307                context = self)
308
309        _logger.debug("Creating %d variants with seed %d.", count, seed)
310
311        all_used_question_indexes: typing.List[typing.Set[int]] = [set() for _ in self.children]
312        variants = []
313
314        for i in range(count):
315            variant_id: typing.Union[str, None] = None
316            if ((count > 1) or (include_solo_identifier)):
317                variant_id = identifiers[i]
318
319            variants.append(self._create_variant(variant_id, rng, all_used_question_indexes, all_questions))
320
321        return variants
322
323    def _create_variant(self,
324            variant_id: typing.Union[str, None],
325            rng: random.Random,
326            all_used_question_indexes: typing.List[typing.Set[int]],
327            all_questions: bool,
328            ) -> 'Variant':
329        """ Create a single variant based on this quiz. """
330
331        new_groups = []
332        for (i, group) in enumerate(self.get_groups()):
333            questions = group.choose_variant_questions(all_questions, all_used_question_indexes[i], rng)
334
335            group_data = vars(group).copy()
336            group_data['children'] = questions
337
338            new_groups.append(quizcomp.model.group.Group(**group_data))
339
340        data = vars(self).copy()
341
342        data['name'] = self.name
343        if (variant_id is not None):
344            data['name'] += f" - {variant_id}"
345
346        data['variant_id'] = variant_id
347        data['quiz_name'] = self.name
348        data['children'] = new_groups
349
350        data['version'] = self.version
351        if ((self.version is not None) and (variant_id is not None)):
352            data['version'] = f"{self.version}, Variant: {variant_id}"
353
354        return Variant(**data)
355
356    def to_dir(self,
357            base_dir: str,
358            fetch_images: bool = True,
359            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
360            quiz_base_filename: str = 'quiz',
361            **kwargs: typing.Any) -> None:
362        quiz = typing.cast(Quiz, self.copy())
363
364        quiz.base_dir = os.path.abspath(base_dir)
365        edq.util.dirent.mkdir(quiz.base_dir)
366
367        if (fetch_images):
368            quiz.fetch_and_update_images()
369
370        output_data = quiz.to_dict(context = context)
371
372        # Write the groups in the quiz, but the questions in their own dirs.
373        for (group_index, group) in enumerate(quiz.get_groups()):
374            group_name = f"{group_index:03d} - {group.get_name('Group')}"
375            group_reldir = os.path.join('questions', group_name)
376
377            # Rewrite the group to use question paths.
378            output_data['groups'][group_index]['questions'] = [group_reldir]  # type: ignore[index,call-overload]
379
380            # Output each question.
381            for (question_index, question) in enumerate(group.get_questions()):
382                question_name = f"{question_index:03d} - {question.get_name('Question')}"
383                question_out_dir = os.path.join(quiz.base_dir, group_reldir, question_name)
384                question.to_dir(question_out_dir, fetch_images = fetch_images, context = context, **kwargs)
385
386        # Move the description to a different file.
387        output_data.pop('description', None)
388        if (not quiz.description.is_empty()):
389            edq.util.dirent.write_file(os.path.join(quiz.base_dir, f"{quiz_base_filename}.md"), quiz.description.to_md())
390
391        edq.util.json.dump_path(output_data, os.path.join(quiz.base_dir, f"{quiz_base_filename}.json"), indent = 4)
392
393class Variant(Quiz):
394    """
395    A quiz variant is an instantiation of a quiz with specific set of questions chosen for each group.
396    Variants still have question groups, but each group must only have the exact number of questions required for each group
397    (or it is a validation error).
398
399    Variants created directly from quizzes (as opposed to from a JSON file)
400    will already have all the correct components, and will therefore only be lightly validated.
401    Quizzes created from files will undergo full validation.
402    """
403
404    def __init__(self,
405            quiz_name: str,
406            variant_id: str,
407            **kwargs: typing.Any,
408            ) -> None:
409        super().__init__(**kwargs)
410
411        self.quiz_name: str = quiz_name
412        """ The name of the quiz this variant was generated from. """
413
414        self.variant_id: str = variant_id
415        """ An identifier to differentiate this variant from its siblings. """
416
417    @staticmethod
418    def get_dummy(
419            question: quizcomp.model.question.Question,
420            seed: typing.Union[int, None] = None,
421            ) -> 'Variant':
422        """
423        Get a "dummy" variant that has no real information.
424        """
425
426        question = typing.cast(quizcomp.model.question.Question, question.copy())
427        group = quizcomp.model.group.Group(children = [question], **DUMMY_GROUP_DATA.copy())
428        quiz = Quiz(children = [group], **DUMMY_QUIZ_DATA.copy())
429
430        return quiz.create_variant(seed = seed)
DUMMY_QUIZ_DATA: Dict[str, Any] = {'name': 'Dummy Name', 'description': Dummy description., 'course_name': 'Dummy Course', 'term_name': 'Dummy Term', 'version': '0.0.0', 'shuffle_answers': False}
DUMMY_GROUP_DATA: Dict[str, Any] = {'name': 'Dummy Question'}
DEFAULT_VARIANT_IDS: List[str] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']

Default IDs for quiz variants.

DEFAULT_MAX_VARIANTS: int = 26
class HideResultsBehavior(enum.Enum):
41class HideResultsBehavior(enum.Enum):
42    """
43    The allowed behaviors for hiding results from students for a quiz with multiple attempts.
44    """
45
46    ALWASY_HIDE = 'always'
47    """ Students can never see their results. """
48
49    NEVER_HIDE = 'never'
50    """ Students can see their results after each attempt. """
51
52    UNTIL_AFTER_LAST_ATTEMPT = 'until_after_last_attempt'
53    """ Students can see their results after each attempt. """

The allowed behaviors for hiding results from students for a quiz with multiple attempts.

ALWASY_HIDE = <HideResultsBehavior.ALWASY_HIDE: 'always'>

Students can never see their results.

NEVER_HIDE = <HideResultsBehavior.NEVER_HIDE: 'never'>

Students can see their results after each attempt.

UNTIL_AFTER_LAST_ATTEMPT = <HideResultsBehavior.UNTIL_AFTER_LAST_ATTEMPT: 'until_after_last_attempt'>

Students can see their results after each attempt.

class ScoringPolicy(enum.Enum):
55class ScoringPolicy(enum.Enum):
56    """
57    The allowed scoring policies for quizzes with multiple attempts.
58    """
59
60    KEEP_HIGHEST = 'keep_highest'
61    """ Keep the highest score from all attempts. """
62
63    KEEP_LATEST = 'keep_latest'
64    """ Keep the most recent score from all attempts. """

The allowed scoring policies for quizzes with multiple attempts.

KEEP_HIGHEST = <ScoringPolicy.KEEP_HIGHEST: 'keep_highest'>

Keep the highest score from all attempts.

KEEP_LATEST = <ScoringPolicy.KEEP_LATEST: 'keep_latest'>

Keep the most recent score from all attempts.

class Quiz(quizcomp.model.base.CoreType):
 66class Quiz(quizcomp.model.base.CoreType):
 67    """
 68    A quiz object represents multiple possible assessments (called "variants").
 69    """
 70
 71    def __init__(self,
 72            children: typing.Union[typing.List[quizcomp.model.group.Group], None] = None,
 73            description: typing.Union[quizcomp.parser.document.ParsedDocument, str, None] = None,
 74            course_name: typing.Union[str, None] = None,
 75            term_name: typing.Union[str, None] = None,
 76            date: typing.Union[edq.util.time.Timestamp, None] = None,
 77            time_limit_mins: typing.Union[int, None] = None,
 78            version: typing.Union[str, None] = None,
 79            practice: typing.Union[bool, None] = None,
 80            publish: typing.Union[bool, None] = None,
 81            assignment_group: typing.Union[str, None] = None,
 82            allowed_attempts: typing.Union[int, None] = None,
 83            show_correct_answers: typing.Union[bool, None] = None,
 84            hide_results: typing.Union[HideResultsBehavior, str, None] = None,
 85            scoring_policy: typing.Union[ScoringPolicy, str, None] = None,
 86            **kwargs: typing.Any) -> None:
 87        # Remove aliases before super construction.
 88        kwargs.pop('groups', None)
 89
 90        super().__init__(children = children, **kwargs)
 91
 92        self.course_name: typing.Union[str, None] = course_name
 93        """ The optional name for the course associated with this quiz. """
 94
 95        self.term_name: typing.Union[str, None] = term_name
 96        """ The optional name of the term this quiz takes place during (e.g., "Fall 20XX"). """
 97
 98        self.date: typing.Union[edq.util.time.Timestamp, None] = date
 99        """ The optional date of this quiz. """
100
101        if (description is None):
102            description = quizcomp.parser.document.ParsedDocument()
103
104        if (isinstance(description, str)):
105            description = quizcomp.parser.document.ParsedDocument.parse_text(description)
106
107        self.description: quizcomp.parser.document.ParsedDocument = description
108        """ The description/prompt for this quiz. """
109
110        if ((time_limit_mins is not None) and (time_limit_mins < 0)):
111            time_limit_mins = None
112
113        self.time_limit_mins: typing.Union[int, None] = time_limit_mins
114        """ The time limit (in minutes) for this quiz. """
115
116        self.version: typing.Union[str, None] = version
117        """ The version of this quiz. """
118
119        self.practice: typing.Union[bool, None] = practice
120        """
121        Whether this quiz should be considered a "practice" quiz.
122        This may change the behavior of different quizzes when uploaded to different platforms.
123        """
124
125        self.publish: typing.Union[bool, None] = publish
126        """
127        Whether this quiz should be considered published on upload.
128        "Published" quizzes are typically visible to students after upload.
129        """
130
131        self.assignment_group: typing.Union[str, None] = assignment_group
132        """
133        The name of the assignment group that this quiz should be uploaded under.
134        Unnecessary if this quiz is not uploaded.
135        """
136
137        self.allowed_attempts: typing.Union[int, None] = allowed_attempts
138        """ The number of attempts a student should have when taking this quiz. """
139
140        self.show_correct_answers: typing.Union[bool, None] = show_correct_answers
141        """ Show students the correct answer after submission. """
142
143        if (isinstance(hide_results, str)):
144            if (not edq.util.enum.has_value(HideResultsBehavior, hide_results)):
145                _logger.warning("Unknown enum value for 'hide_results': '%s'. Setting to null.", hide_results)
146                hide_results = None
147            else:
148                hide_results = HideResultsBehavior(hide_results)
149
150        self.hide_results: typing.Union[HideResultsBehavior, None] = hide_results
151        """ The behavior for showing results to students when multiple attempts are allowed. """
152
153        if (isinstance(scoring_policy, str)):
154            if (not edq.util.enum.has_value(ScoringPolicy, scoring_policy)):
155                _logger.warning("Unknown enum value for 'scoring_policy': '%s'. Setting to null.", scoring_policy)
156                scoring_policy = None
157            else:
158                scoring_policy = ScoringPolicy(scoring_policy)
159
160        self.scoring_policy: typing.Union[ScoringPolicy, None] = scoring_policy
161        """ The scoring behavior when multiple attempts are allowed. """
162
163        self._validate()
164
165    def _validate(self) -> None:
166        """ Check if this quiz is valid. """
167
168        if (self.name is None):
169            raise quizcomp.model.errors.QuizValidationError("Quiz name cannot be empty.", context = self)
170
171    def get_groups(self) -> typing.List[quizcomp.model.group.Group]:
172        """ Get all groups for this quiz. """
173
174        return [typing.cast(quizcomp.model.group.Group, child) for child in self.children]
175
176    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
177        return [self.description]
178
179    @classmethod
180    def prep_init_data(cls,
181            data: typing.Dict[str, typing.Any],
182            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
183            ) -> typing.Dict[str, typing.Any]:
184        if (context is None):
185            context = edq.util.serial.SerializationContext()
186
187        data = super().prep_init_data(data, context)
188
189        data['description'] = cls._collect_description(data, context)
190
191        return data
192
193    @classmethod
194    def _collect_description(cls,
195            data: typing.Dict[str, typing.Any],
196            context: edq.util.serial.SerializationContext,
197            ) -> quizcomp.parser.document.ParsedDocument:
198        """
199        Collect the description from one of several possible locations.
200
201        The description is allowed to appear (in order of priority):
202        1) in the `description` field.
203        2) pointed to by the `description_path` field.
204        3) or be in the same path as the quiz JSON, but with an `.md` extension
205           (e.g., `a/b/my_quiz.json` and `a/b/my_quiz.md`).
206
207        None values will be ignored (but empty values are valid).
208        Will return an empty description if none of these are present.
209        """
210
211        # If we have a quiz path, use that to resolve paths.
212        default_description_path = None
213        if (context.source_path is not None):
214            context.source_path = os.path.abspath(context.source_path)
215            context.base_dir = os.path.dirname(context.source_path)
216            default_description_path = os.path.splitext(context.source_path)[0] + '.md'
217
218        # Check the `description` field.
219        text = data.get('description', None)
220        if (text is not None):
221            return quizcomp.parser.document.ParsedDocument.parse_text(text, context)
222
223        # Check for an explicitly provided path.
224        description_path = data.get('description_path', None)
225        if (description_path is not None):
226            if (not os.path.isabs(description_path)):
227                description_path = os.path.join(context.base_dir, description_path)
228
229            description_path = os.path.abspath(description_path)
230
231            if (not os.path.isfile(description_path)):
232                raise quizcomp.model.errors.QuestionValidationError(
233                        f"Could not find a description at the provided path: '{data['description_path']}' (Absolute Path: '{description_path}').",
234                        context = context)
235
236            return quizcomp.parser.document.ParsedDocument.parse_file(description_path)
237
238        # Check for an implicit path.
239        if ((default_description_path is not None) and os.path.isfile(default_description_path)):
240            return quizcomp.parser.document.ParsedDocument.parse_file(default_description_path)
241
242        return quizcomp.parser.document.ParsedDocument()
243
244    def to_dict(self,
245            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
246            ) -> typing.Dict[str, edq.util.serial.PODType]:
247        data = super().to_dict(context)
248        data['groups'] = data.pop('children', data.get('groups', None))
249        return data
250
251    @classmethod
252    def from_dict(cls,
253            data: typing.Dict[str, edq.util.serial.PODType],
254            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
255            ) -> 'Quiz':
256        data['children'] = data.pop('groups', data.get('children', None))
257        return super().from_dict(data, context)
258
259    def create_variant(self,
260            seed: typing.Union[int, None] = None,
261            identifiers: typing.Union[typing.List[str], None] = None,
262            all_questions: bool = False,
263            include_solo_identifier: bool = False,
264            ) -> 'Variant':
265        """ A convenience call to create_variants(). """
266
267        return self.create_variants(
268            count = 1,
269            seed = seed,
270            identifiers = identifiers,
271            all_questions = all_questions, include_solo_identifier = include_solo_identifier,
272        )[0]
273
274    def create_variants(self,
275            count: int = 1,
276            seed: typing.Union[int, None] = None,
277            identifiers: typing.Union[typing.List[str], None] = None,
278            all_questions: bool = False,
279            include_solo_identifier: bool = False,
280            ) -> typing.List['Variant']:
281        """
282        Create a collection of variants based on this quiz.
283        These variants will share the same question pool,
284        which is influenced by the `pick_with_replacement` config option.
285
286        Setting `include_solo_identifier` to true will include an identifier (e.g., " - A")
287        in the name of variants when only one variant is created.
288        """
289
290        if (seed is None):
291            seed = random.randint(0, 2**64)
292
293        rng = random.Random(seed)
294
295        if (identifiers is None):
296            identifiers = DEFAULT_VARIANT_IDS
297
298        if (count < 0):
299            raise quizcomp.model.errors.QuizValidationError(
300                    f"Variant count must be non-negative, found: {count}.",
301                    context = self)
302
303        if (count > len(identifiers)):
304            raise quizcomp.model.errors.QuizValidationError(
305                ('"Not enough variant identifiers supplied.'
306                    + f" Got {len(identifiers)} identifiers and {count} requested variants."
307                    + f" Given identifiers: {identifiers}."),
308                context = self)
309
310        _logger.debug("Creating %d variants with seed %d.", count, seed)
311
312        all_used_question_indexes: typing.List[typing.Set[int]] = [set() for _ in self.children]
313        variants = []
314
315        for i in range(count):
316            variant_id: typing.Union[str, None] = None
317            if ((count > 1) or (include_solo_identifier)):
318                variant_id = identifiers[i]
319
320            variants.append(self._create_variant(variant_id, rng, all_used_question_indexes, all_questions))
321
322        return variants
323
324    def _create_variant(self,
325            variant_id: typing.Union[str, None],
326            rng: random.Random,
327            all_used_question_indexes: typing.List[typing.Set[int]],
328            all_questions: bool,
329            ) -> 'Variant':
330        """ Create a single variant based on this quiz. """
331
332        new_groups = []
333        for (i, group) in enumerate(self.get_groups()):
334            questions = group.choose_variant_questions(all_questions, all_used_question_indexes[i], rng)
335
336            group_data = vars(group).copy()
337            group_data['children'] = questions
338
339            new_groups.append(quizcomp.model.group.Group(**group_data))
340
341        data = vars(self).copy()
342
343        data['name'] = self.name
344        if (variant_id is not None):
345            data['name'] += f" - {variant_id}"
346
347        data['variant_id'] = variant_id
348        data['quiz_name'] = self.name
349        data['children'] = new_groups
350
351        data['version'] = self.version
352        if ((self.version is not None) and (variant_id is not None)):
353            data['version'] = f"{self.version}, Variant: {variant_id}"
354
355        return Variant(**data)
356
357    def to_dir(self,
358            base_dir: str,
359            fetch_images: bool = True,
360            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
361            quiz_base_filename: str = 'quiz',
362            **kwargs: typing.Any) -> None:
363        quiz = typing.cast(Quiz, self.copy())
364
365        quiz.base_dir = os.path.abspath(base_dir)
366        edq.util.dirent.mkdir(quiz.base_dir)
367
368        if (fetch_images):
369            quiz.fetch_and_update_images()
370
371        output_data = quiz.to_dict(context = context)
372
373        # Write the groups in the quiz, but the questions in their own dirs.
374        for (group_index, group) in enumerate(quiz.get_groups()):
375            group_name = f"{group_index:03d} - {group.get_name('Group')}"
376            group_reldir = os.path.join('questions', group_name)
377
378            # Rewrite the group to use question paths.
379            output_data['groups'][group_index]['questions'] = [group_reldir]  # type: ignore[index,call-overload]
380
381            # Output each question.
382            for (question_index, question) in enumerate(group.get_questions()):
383                question_name = f"{question_index:03d} - {question.get_name('Question')}"
384                question_out_dir = os.path.join(quiz.base_dir, group_reldir, question_name)
385                question.to_dir(question_out_dir, fetch_images = fetch_images, context = context, **kwargs)
386
387        # Move the description to a different file.
388        output_data.pop('description', None)
389        if (not quiz.description.is_empty()):
390            edq.util.dirent.write_file(os.path.join(quiz.base_dir, f"{quiz_base_filename}.md"), quiz.description.to_md())
391
392        edq.util.json.dump_path(output_data, os.path.join(quiz.base_dir, f"{quiz_base_filename}.json"), indent = 4)

A quiz object represents multiple possible assessments (called "variants").

Quiz( children: Optional[List[quizcomp.model.group.Group]] = None, description: Union[quizcomp.parser.document.ParsedDocument, str, NoneType] = None, course_name: Optional[str] = None, term_name: Optional[str] = None, date: Optional[edq.util.time.Timestamp] = None, time_limit_mins: Optional[int] = None, version: Optional[str] = None, practice: Optional[bool] = None, publish: Optional[bool] = None, assignment_group: Optional[str] = None, allowed_attempts: Optional[int] = None, show_correct_answers: Optional[bool] = None, hide_results: Union[HideResultsBehavior, str, NoneType] = None, scoring_policy: Union[ScoringPolicy, str, NoneType] = None, **kwargs: Any)
 71    def __init__(self,
 72            children: typing.Union[typing.List[quizcomp.model.group.Group], None] = None,
 73            description: typing.Union[quizcomp.parser.document.ParsedDocument, str, None] = None,
 74            course_name: typing.Union[str, None] = None,
 75            term_name: typing.Union[str, None] = None,
 76            date: typing.Union[edq.util.time.Timestamp, None] = None,
 77            time_limit_mins: typing.Union[int, None] = None,
 78            version: typing.Union[str, None] = None,
 79            practice: typing.Union[bool, None] = None,
 80            publish: typing.Union[bool, None] = None,
 81            assignment_group: typing.Union[str, None] = None,
 82            allowed_attempts: typing.Union[int, None] = None,
 83            show_correct_answers: typing.Union[bool, None] = None,
 84            hide_results: typing.Union[HideResultsBehavior, str, None] = None,
 85            scoring_policy: typing.Union[ScoringPolicy, str, None] = None,
 86            **kwargs: typing.Any) -> None:
 87        # Remove aliases before super construction.
 88        kwargs.pop('groups', None)
 89
 90        super().__init__(children = children, **kwargs)
 91
 92        self.course_name: typing.Union[str, None] = course_name
 93        """ The optional name for the course associated with this quiz. """
 94
 95        self.term_name: typing.Union[str, None] = term_name
 96        """ The optional name of the term this quiz takes place during (e.g., "Fall 20XX"). """
 97
 98        self.date: typing.Union[edq.util.time.Timestamp, None] = date
 99        """ The optional date of this quiz. """
100
101        if (description is None):
102            description = quizcomp.parser.document.ParsedDocument()
103
104        if (isinstance(description, str)):
105            description = quizcomp.parser.document.ParsedDocument.parse_text(description)
106
107        self.description: quizcomp.parser.document.ParsedDocument = description
108        """ The description/prompt for this quiz. """
109
110        if ((time_limit_mins is not None) and (time_limit_mins < 0)):
111            time_limit_mins = None
112
113        self.time_limit_mins: typing.Union[int, None] = time_limit_mins
114        """ The time limit (in minutes) for this quiz. """
115
116        self.version: typing.Union[str, None] = version
117        """ The version of this quiz. """
118
119        self.practice: typing.Union[bool, None] = practice
120        """
121        Whether this quiz should be considered a "practice" quiz.
122        This may change the behavior of different quizzes when uploaded to different platforms.
123        """
124
125        self.publish: typing.Union[bool, None] = publish
126        """
127        Whether this quiz should be considered published on upload.
128        "Published" quizzes are typically visible to students after upload.
129        """
130
131        self.assignment_group: typing.Union[str, None] = assignment_group
132        """
133        The name of the assignment group that this quiz should be uploaded under.
134        Unnecessary if this quiz is not uploaded.
135        """
136
137        self.allowed_attempts: typing.Union[int, None] = allowed_attempts
138        """ The number of attempts a student should have when taking this quiz. """
139
140        self.show_correct_answers: typing.Union[bool, None] = show_correct_answers
141        """ Show students the correct answer after submission. """
142
143        if (isinstance(hide_results, str)):
144            if (not edq.util.enum.has_value(HideResultsBehavior, hide_results)):
145                _logger.warning("Unknown enum value for 'hide_results': '%s'. Setting to null.", hide_results)
146                hide_results = None
147            else:
148                hide_results = HideResultsBehavior(hide_results)
149
150        self.hide_results: typing.Union[HideResultsBehavior, None] = hide_results
151        """ The behavior for showing results to students when multiple attempts are allowed. """
152
153        if (isinstance(scoring_policy, str)):
154            if (not edq.util.enum.has_value(ScoringPolicy, scoring_policy)):
155                _logger.warning("Unknown enum value for 'scoring_policy': '%s'. Setting to null.", scoring_policy)
156                scoring_policy = None
157            else:
158                scoring_policy = ScoringPolicy(scoring_policy)
159
160        self.scoring_policy: typing.Union[ScoringPolicy, None] = scoring_policy
161        """ The scoring behavior when multiple attempts are allowed. """
162
163        self._validate()
course_name: Optional[str]

The optional name for the course associated with this quiz.

term_name: Optional[str]

The optional name of the term this quiz takes place during (e.g., "Fall 20XX").

date: Optional[edq.util.time.Timestamp]

The optional date of this quiz.

The description/prompt for this quiz.

time_limit_mins: Optional[int]

The time limit (in minutes) for this quiz.

version: Optional[str]

The version of this quiz.

practice: Optional[bool]

Whether this quiz should be considered a "practice" quiz. This may change the behavior of different quizzes when uploaded to different platforms.

publish: Optional[bool]

Whether this quiz should be considered published on upload. "Published" quizzes are typically visible to students after upload.

assignment_group: Optional[str]

The name of the assignment group that this quiz should be uploaded under. Unnecessary if this quiz is not uploaded.

allowed_attempts: Optional[int]

The number of attempts a student should have when taking this quiz.

show_correct_answers: Optional[bool]

Show students the correct answer after submission.

hide_results: Optional[HideResultsBehavior]

The behavior for showing results to students when multiple attempts are allowed.

scoring_policy: Optional[ScoringPolicy]

The scoring behavior when multiple attempts are allowed.

def get_groups(self) -> List[quizcomp.model.group.Group]:
171    def get_groups(self) -> typing.List[quizcomp.model.group.Group]:
172        """ Get all groups for this quiz. """
173
174        return [typing.cast(quizcomp.model.group.Group, child) for child in self.children]

Get all groups for this quiz.

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
176    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
177        return [self.description]

Collect documents for this object only (does not include any children). Use collect_all_documents() if you want child documents as well.

@classmethod
def prep_init_data( cls, data: Dict[str, Any], context: Optional[edq.util.common.SerializationContext] = None) -> Dict[str, Any]:
179    @classmethod
180    def prep_init_data(cls,
181            data: typing.Dict[str, typing.Any],
182            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
183            ) -> typing.Dict[str, typing.Any]:
184        if (context is None):
185            context = edq.util.serial.SerializationContext()
186
187        data = super().prep_init_data(data, context)
188
189        data['description'] = cls._collect_description(data, context)
190
191        return data

Prepare data to be passed into this class' constructor.

By default, this is called by from_pod(). A child can override this or prep_init_data() depending on the functionality they want.

A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.

def to_dict( self, context: Optional[edq.util.common.SerializationContext] = None) -> Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]:
244    def to_dict(self,
245            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
246            ) -> typing.Dict[str, edq.util.serial.PODType]:
247        data = super().to_dict(context)
248        data['groups'] = data.pop('children', data.get('groups', None))
249        return data

Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.

A general (but inefficient) implementation is provided by default.

@classmethod
def from_dict( cls, data: Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], context: Optional[edq.util.common.SerializationContext] = None) -> Quiz:
251    @classmethod
252    def from_dict(cls,
253            data: typing.Dict[str, edq.util.serial.PODType],
254            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
255            ) -> 'Quiz':
256        data['children'] = data.pop('groups', data.get('children', None))
257        return super().from_dict(data, context)

Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be equivalent to the original.

By default, this function just calls the class' constructor with the output of prep_init_data(). A child can override this or prep_init_data() depending on the functionality they want.

A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.

def create_variant( self, seed: Optional[int] = None, identifiers: Optional[List[str]] = None, all_questions: bool = False, include_solo_identifier: bool = False) -> Variant:
259    def create_variant(self,
260            seed: typing.Union[int, None] = None,
261            identifiers: typing.Union[typing.List[str], None] = None,
262            all_questions: bool = False,
263            include_solo_identifier: bool = False,
264            ) -> 'Variant':
265        """ A convenience call to create_variants(). """
266
267        return self.create_variants(
268            count = 1,
269            seed = seed,
270            identifiers = identifiers,
271            all_questions = all_questions, include_solo_identifier = include_solo_identifier,
272        )[0]

A convenience call to create_variants().

def create_variants( self, count: int = 1, seed: Optional[int] = None, identifiers: Optional[List[str]] = None, all_questions: bool = False, include_solo_identifier: bool = False) -> List[Variant]:
274    def create_variants(self,
275            count: int = 1,
276            seed: typing.Union[int, None] = None,
277            identifiers: typing.Union[typing.List[str], None] = None,
278            all_questions: bool = False,
279            include_solo_identifier: bool = False,
280            ) -> typing.List['Variant']:
281        """
282        Create a collection of variants based on this quiz.
283        These variants will share the same question pool,
284        which is influenced by the `pick_with_replacement` config option.
285
286        Setting `include_solo_identifier` to true will include an identifier (e.g., " - A")
287        in the name of variants when only one variant is created.
288        """
289
290        if (seed is None):
291            seed = random.randint(0, 2**64)
292
293        rng = random.Random(seed)
294
295        if (identifiers is None):
296            identifiers = DEFAULT_VARIANT_IDS
297
298        if (count < 0):
299            raise quizcomp.model.errors.QuizValidationError(
300                    f"Variant count must be non-negative, found: {count}.",
301                    context = self)
302
303        if (count > len(identifiers)):
304            raise quizcomp.model.errors.QuizValidationError(
305                ('"Not enough variant identifiers supplied.'
306                    + f" Got {len(identifiers)} identifiers and {count} requested variants."
307                    + f" Given identifiers: {identifiers}."),
308                context = self)
309
310        _logger.debug("Creating %d variants with seed %d.", count, seed)
311
312        all_used_question_indexes: typing.List[typing.Set[int]] = [set() for _ in self.children]
313        variants = []
314
315        for i in range(count):
316            variant_id: typing.Union[str, None] = None
317            if ((count > 1) or (include_solo_identifier)):
318                variant_id = identifiers[i]
319
320            variants.append(self._create_variant(variant_id, rng, all_used_question_indexes, all_questions))
321
322        return variants

Create a collection of variants based on this quiz. These variants will share the same question pool, which is influenced by the pick_with_replacement config option.

Setting include_solo_identifier to true will include an identifier (e.g., " - A") in the name of variants when only one variant is created.

def to_dir( self, base_dir: str, fetch_images: bool = True, context: Optional[edq.util.common.SerializationContext] = None, quiz_base_filename: str = 'quiz', **kwargs: Any) -> None:
357    def to_dir(self,
358            base_dir: str,
359            fetch_images: bool = True,
360            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
361            quiz_base_filename: str = 'quiz',
362            **kwargs: typing.Any) -> None:
363        quiz = typing.cast(Quiz, self.copy())
364
365        quiz.base_dir = os.path.abspath(base_dir)
366        edq.util.dirent.mkdir(quiz.base_dir)
367
368        if (fetch_images):
369            quiz.fetch_and_update_images()
370
371        output_data = quiz.to_dict(context = context)
372
373        # Write the groups in the quiz, but the questions in their own dirs.
374        for (group_index, group) in enumerate(quiz.get_groups()):
375            group_name = f"{group_index:03d} - {group.get_name('Group')}"
376            group_reldir = os.path.join('questions', group_name)
377
378            # Rewrite the group to use question paths.
379            output_data['groups'][group_index]['questions'] = [group_reldir]  # type: ignore[index,call-overload]
380
381            # Output each question.
382            for (question_index, question) in enumerate(group.get_questions()):
383                question_name = f"{question_index:03d} - {question.get_name('Question')}"
384                question_out_dir = os.path.join(quiz.base_dir, group_reldir, question_name)
385                question.to_dir(question_out_dir, fetch_images = fetch_images, context = context, **kwargs)
386
387        # Move the description to a different file.
388        output_data.pop('description', None)
389        if (not quiz.description.is_empty()):
390            edq.util.dirent.write_file(os.path.join(quiz.base_dir, f"{quiz_base_filename}.md"), quiz.description.to_md())
391
392        edq.util.json.dump_path(output_data, os.path.join(quiz.base_dir, f"{quiz_base_filename}.json"), indent = 4)

Write this object to the given directory. This is different than to_path(), as that function just serializes the to a single JSON file, whereas this method writes a directory in the Quiz Composer style.

class Variant(Quiz):
394class Variant(Quiz):
395    """
396    A quiz variant is an instantiation of a quiz with specific set of questions chosen for each group.
397    Variants still have question groups, but each group must only have the exact number of questions required for each group
398    (or it is a validation error).
399
400    Variants created directly from quizzes (as opposed to from a JSON file)
401    will already have all the correct components, and will therefore only be lightly validated.
402    Quizzes created from files will undergo full validation.
403    """
404
405    def __init__(self,
406            quiz_name: str,
407            variant_id: str,
408            **kwargs: typing.Any,
409            ) -> None:
410        super().__init__(**kwargs)
411
412        self.quiz_name: str = quiz_name
413        """ The name of the quiz this variant was generated from. """
414
415        self.variant_id: str = variant_id
416        """ An identifier to differentiate this variant from its siblings. """
417
418    @staticmethod
419    def get_dummy(
420            question: quizcomp.model.question.Question,
421            seed: typing.Union[int, None] = None,
422            ) -> 'Variant':
423        """
424        Get a "dummy" variant that has no real information.
425        """
426
427        question = typing.cast(quizcomp.model.question.Question, question.copy())
428        group = quizcomp.model.group.Group(children = [question], **DUMMY_GROUP_DATA.copy())
429        quiz = Quiz(children = [group], **DUMMY_QUIZ_DATA.copy())
430
431        return quiz.create_variant(seed = seed)

A quiz variant is an instantiation of a quiz with specific set of questions chosen for each group. Variants still have question groups, but each group must only have the exact number of questions required for each group (or it is a validation error).

Variants created directly from quizzes (as opposed to from a JSON file) will already have all the correct components, and will therefore only be lightly validated. Quizzes created from files will undergo full validation.

Variant(quiz_name: str, variant_id: str, **kwargs: Any)
405    def __init__(self,
406            quiz_name: str,
407            variant_id: str,
408            **kwargs: typing.Any,
409            ) -> None:
410        super().__init__(**kwargs)
411
412        self.quiz_name: str = quiz_name
413        """ The name of the quiz this variant was generated from. """
414
415        self.variant_id: str = variant_id
416        """ An identifier to differentiate this variant from its siblings. """
quiz_name: str

The name of the quiz this variant was generated from.

variant_id: str

An identifier to differentiate this variant from its siblings.

@staticmethod
def get_dummy( question: quizcomp.model.question.Question, seed: Optional[int] = None) -> Variant:
418    @staticmethod
419    def get_dummy(
420            question: quizcomp.model.question.Question,
421            seed: typing.Union[int, None] = None,
422            ) -> 'Variant':
423        """
424        Get a "dummy" variant that has no real information.
425        """
426
427        question = typing.cast(quizcomp.model.question.Question, question.copy())
428        group = quizcomp.model.group.Group(children = [question], **DUMMY_GROUP_DATA.copy())
429        quiz = Quiz(children = [group], **DUMMY_QUIZ_DATA.copy())
430
431        return quiz.create_variant(seed = seed)

Get a "dummy" variant that has no real information.